From 40308f2f2cb0146a082bbcc44619d827b2044e59 Mon Sep 17 00:00:00 2001 From: ascandone Date: Thu, 19 Jun 2025 18:15:49 +0200 Subject: [PATCH 01/59] feat: define and parse specs format --- internal/specs_format/index.go | 27 ++++++++++++++ internal/specs_format/parse_test.go | 57 +++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 internal/specs_format/index.go create mode 100644 internal/specs_format/parse_test.go diff --git a/internal/specs_format/index.go b/internal/specs_format/index.go new file mode 100644 index 00000000..a93ff735 --- /dev/null +++ b/internal/specs_format/index.go @@ -0,0 +1,27 @@ +package specs_format + +// TODO handle big ints + +// --- Inputs +type Balances = map[string]map[string]int64 +type AccountsMeta = map[string]map[string]string +type Vars = map[string]string + +// --- Outputs +type Posting struct { + Source string `json:"source"` + Destination string `json:"destination"` + Amount int64 `json:"amount"` + Asset string `json:"asset"` +} +type TxMeta = map[string]string + +// --- Specs: +type Specs struct { + It string `json:"it"` + Balances Balances `json:"balances,omitempty"` + Vars Vars `json:"vars,omitempty"` + Meta AccountsMeta `json:"accountsMeta,omitempty"` + TestCases []Specs `json:"testCases,omitempty"` + ExpectedPostings []Posting `json:"expectedPostings,omitempty"` +} diff --git a/internal/specs_format/parse_test.go b/internal/specs_format/parse_test.go new file mode 100644 index 00000000..2eb970a2 --- /dev/null +++ b/internal/specs_format/parse_test.go @@ -0,0 +1,57 @@ +package specs_format_test + +import ( + "encoding/json" + "testing" + + "github.com/formancehq/numscript/internal/specs_format" + "github.com/stretchr/testify/require" +) + +func TestParseSpecs(t *testing.T) { + + raw := ` + { + "it": "d1", + "balances": { + "alice": { "EUR": 200 } + }, + "vars": { + "amt": "200" + }, + "expectedPostings": [ + { + "source": "src", + "destination": "dest", + "asset": "EUR", + "amount": 100 + } + ] + } + ` + + var specs specs_format.Specs + err := json.Unmarshal([]byte(raw), &specs) + require.Nil(t, err) + + require.Equal(t, specs_format.Specs{ + It: "d1", + Balances: map[string]map[string]int64{ + "alice": { + "EUR": 200, + }, + }, + Vars: map[string]string{ + "amt": "200", + }, + ExpectedPostings: []specs_format.Posting{ + { + Source: "src", + Destination: "dest", + Asset: "EUR", + Amount: 100, + }, + }, + }, specs) + +} From 782292b89904855f4b29e79378e308e593505aeb Mon Sep 17 00:00:00 2001 From: ascandone Date: Thu, 19 Jun 2025 19:15:58 +0200 Subject: [PATCH 02/59] quick and dirty prototype --- internal/cmd/root.go | 1 + internal/cmd/test.go | 57 +++++++++++++++++++++++ internal/specs_format/index.go | 70 ++++++++++++++++++++--------- internal/specs_format/parse_test.go | 12 ++--- 4 files changed, 114 insertions(+), 26 deletions(-) create mode 100644 internal/cmd/test.go diff --git a/internal/cmd/root.go b/internal/cmd/root.go index b19ff0f1..35f993cd 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -25,6 +25,7 @@ func Execute(options CliOptions) { rootCmd.AddCommand(lspCmd) rootCmd.AddCommand(checkCmd) + rootCmd.AddCommand(testCmd) rootCmd.AddCommand(getRunCmd()) if err := rootCmd.Execute(); err != nil { diff --git a/internal/cmd/test.go b/internal/cmd/test.go new file mode 100644 index 00000000..007ea252 --- /dev/null +++ b/internal/cmd/test.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/specs_format" + "github.com/spf13/cobra" +) + +func test(path string) { + numscriptContent, err := os.ReadFile(path) + if err != nil { + os.Stderr.Write([]byte(err.Error())) + return + } + + parseResult := parser.Parse(string(numscriptContent)) + // TODO assert no parse err + // TODO we might want to do static checking + + specsFileContent, err := os.ReadFile(path + ".specs.json") + if err != nil { + os.Stderr.Write([]byte(err.Error())) + return + } + + var specs specs_format.Specs + err = json.Unmarshal([]byte(specsFileContent), &specs) + if err != nil { + os.Stderr.Write([]byte(err.Error())) + return + } + + out, err := specs_format.Run(parseResult.Value, specs) + if err != nil { + os.Stderr.Write([]byte(err.Error())) + return + } + + if !out.Success { + fmt.Printf("Postings mismatch.\n\tExpected: %v\n\tGot:%v\n", out.ExpectedPostings, out.ActualPostings) + } +} + +// TODO test directory instead +var testCmd = &cobra.Command{ + Use: "test ", + Short: "Test a numscript file, using the corresponding spec file", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + path := args[0] + test(path) + }, +} diff --git a/internal/specs_format/index.go b/internal/specs_format/index.go index a93ff735..f6c03e2a 100644 --- a/internal/specs_format/index.go +++ b/internal/specs_format/index.go @@ -1,27 +1,55 @@ package specs_format -// TODO handle big ints - -// --- Inputs -type Balances = map[string]map[string]int64 -type AccountsMeta = map[string]map[string]string -type Vars = map[string]string - -// --- Outputs -type Posting struct { - Source string `json:"source"` - Destination string `json:"destination"` - Amount int64 `json:"amount"` - Asset string `json:"asset"` -} -type TxMeta = map[string]string +import ( + "context" + "reflect" + + "github.com/formancehq/numscript/internal/interpreter" + "github.com/formancehq/numscript/internal/parser" +) // --- Specs: type Specs struct { - It string `json:"it"` - Balances Balances `json:"balances,omitempty"` - Vars Vars `json:"vars,omitempty"` - Meta AccountsMeta `json:"accountsMeta,omitempty"` - TestCases []Specs `json:"testCases,omitempty"` - ExpectedPostings []Posting `json:"expectedPostings,omitempty"` + It string `json:"it"` + Balances interpreter.Balances `json:"balances,omitempty"` + Vars interpreter.VariablesMap `json:"vars,omitempty"` + Meta interpreter.AccountsMetadata `json:"accountsMeta,omitempty"` + TestCases []Specs `json:"testCases,omitempty"` + ExpectedPostings []interpreter.Posting `json:"expectedPostings,omitempty"` + // TODO expected tx meta + // TODO expected accountsMeta +} + +type SpecOutput struct { + It string + Success bool + ExpectedPostings []interpreter.Posting + ActualPostings []interpreter.Posting + + // TODO expected tx meta, accountsMeta +} + +func Run(program parser.Program, specs Specs) (SpecOutput, error) { + + result, err := interpreter.RunProgram( + context.Background(), + program, + specs.Vars, + interpreter.StaticStore{ + Balances: specs.Balances, + Meta: specs.Meta, + }, nil) + + if err != nil { + return SpecOutput{}, err + } + + success := reflect.DeepEqual(result.Postings, specs.ExpectedPostings) + + return SpecOutput{ + It: specs.It, + Success: success, + ExpectedPostings: specs.ExpectedPostings, + ActualPostings: result.Postings, + }, nil } diff --git a/internal/specs_format/parse_test.go b/internal/specs_format/parse_test.go index 2eb970a2..28363684 100644 --- a/internal/specs_format/parse_test.go +++ b/internal/specs_format/parse_test.go @@ -2,8 +2,10 @@ package specs_format_test import ( "encoding/json" + "math/big" "testing" + "github.com/formancehq/numscript/internal/interpreter" "github.com/formancehq/numscript/internal/specs_format" "github.com/stretchr/testify/require" ) @@ -36,20 +38,20 @@ func TestParseSpecs(t *testing.T) { require.Equal(t, specs_format.Specs{ It: "d1", - Balances: map[string]map[string]int64{ + Balances: interpreter.Balances{ "alice": { - "EUR": 200, + "EUR": big.NewInt(200), }, }, - Vars: map[string]string{ + Vars: interpreter.VariablesMap{ "amt": "200", }, - ExpectedPostings: []specs_format.Posting{ + ExpectedPostings: []interpreter.Posting{ { Source: "src", Destination: "dest", Asset: "EUR", - Amount: 100, + Amount: big.NewInt(100), }, }, }, specs) From 4b6f9bd93f6d52214782b96956983f49e2161ba3 Mon Sep 17 00:00:00 2001 From: ascandone Date: Fri, 27 Jun 2025 12:38:22 +0200 Subject: [PATCH 03/59] prototype v2 --- internal/ansi/ansi.go | 8 ++ internal/cmd/test.go | 30 +++++-- internal/interpreter/balances.go | 2 +- internal/interpreter/batch_balances_query.go | 2 +- internal/specs_format/index.go | 94 ++++++++++++++------ internal/specs_format/parse_test.go | 63 ++++++++----- 6 files changed, 140 insertions(+), 59 deletions(-) diff --git a/internal/ansi/ansi.go b/internal/ansi/ansi.go index 77eb617b..c68bf300 100644 --- a/internal/ansi/ansi.go +++ b/internal/ansi/ansi.go @@ -13,6 +13,10 @@ func ColorRed(s string) string { return col(s, 31) } +func ColorGreen(s string) string { + return col(s, 32) +} + func ColorYellow(s string) string { return col(s, 33) } @@ -20,3 +24,7 @@ func ColorYellow(s string) string { func ColorCyan(s string) string { return col(s, 36) } + +func Underline(s string) string { + return col(s, 4) +} diff --git a/internal/cmd/test.go b/internal/cmd/test.go index 007ea252..f5d2a445 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -5,6 +5,7 @@ import ( "fmt" "os" + "github.com/formancehq/numscript/internal/ansi" "github.com/formancehq/numscript/internal/parser" "github.com/formancehq/numscript/internal/specs_format" "github.com/spf13/cobra" @@ -34,15 +35,32 @@ func test(path string) { return } - out, err := specs_format.Run(parseResult.Value, specs) - if err != nil { - os.Stderr.Write([]byte(err.Error())) - return + out := specs_format.Run(parseResult.Value, specs) + for _, result := range out.Cases { + if !result.Pass { + fmt.Println(ansi.Underline(`it: ` + result.It)) + + fmt.Println("\nExpected:") + expected, _ := json.MarshalIndent(result.ExpectedPostings, "", " ") + fmt.Println(ansi.ColorGreen(string(expected))) + + fmt.Println("\nGot:") + actual, _ := json.MarshalIndent(result.ActualPostings, "", " ") + fmt.Println(ansi.ColorRed(string(actual))) + + } } - if !out.Success { - fmt.Printf("Postings mismatch.\n\tExpected: %v\n\tGot:%v\n", out.ExpectedPostings, out.ActualPostings) + if out.Total == 0 { + fmt.Println(ansi.ColorRed("Empty test suite!")) + os.Exit(1) + } else if out.Failing == 0 { + fmt.Printf("All tests passing ✅\n") + return + } else { + os.Exit(1) } + } // TODO test directory instead diff --git a/internal/interpreter/balances.go b/internal/interpreter/balances.go index 89c1ba81..30505754 100644 --- a/internal/interpreter/balances.go +++ b/internal/interpreter/balances.go @@ -78,7 +78,7 @@ func (b Balances) filterQuery(q BalanceQuery) BalanceQuery { } // Merge balances by adding balances in the "update" arg -func (b Balances) mergeBalance(update Balances) { +func (b Balances) Merge(update Balances) { // merge queried balance for acc, accBalances := range update { cachedAcc := defaultMapGet(b, acc, func() AccountBalance { diff --git a/internal/interpreter/batch_balances_query.go b/internal/interpreter/batch_balances_query.go index d5a3b40c..cedd25ec 100644 --- a/internal/interpreter/batch_balances_query.go +++ b/internal/interpreter/batch_balances_query.go @@ -77,7 +77,7 @@ func (st *programState) runBalancesQuery() error { // reset batch query st.CurrentBalanceQuery = BalanceQuery{} - st.CachedBalances.mergeBalance(queriedBalances) + st.CachedBalances.Merge(queriedBalances) return nil } diff --git a/internal/specs_format/index.go b/internal/specs_format/index.go index f6c03e2a..fce0f7ff 100644 --- a/internal/specs_format/index.go +++ b/internal/specs_format/index.go @@ -10,46 +10,84 @@ import ( // --- Specs: type Specs struct { + Balances interpreter.Balances `json:"balances,omitempty"` + Vars interpreter.VariablesMap `json:"vars,omitempty"` + Meta interpreter.AccountsMetadata `json:"accountsMeta,omitempty"` + TestCases []TestCase `json:"testCases,omitempty"` +} + +type TestCase struct { It string `json:"it"` Balances interpreter.Balances `json:"balances,omitempty"` Vars interpreter.VariablesMap `json:"vars,omitempty"` Meta interpreter.AccountsMetadata `json:"accountsMeta,omitempty"` - TestCases []Specs `json:"testCases,omitempty"` - ExpectedPostings []interpreter.Posting `json:"expectedPostings,omitempty"` - // TODO expected tx meta - // TODO expected accountsMeta + ExpectedPostings []interpreter.Posting `json:"expectedPostings"` + // TODO expected tx meta, accountsMeta } -type SpecOutput struct { - It string - Success bool - ExpectedPostings []interpreter.Posting - ActualPostings []interpreter.Posting +type TestCaseResult struct { + It string `json:"it"` + Pass bool `json:"pass"` + Balances interpreter.Balances `json:"balances"` + Vars interpreter.VariablesMap `json:"vars"` + Meta interpreter.AccountsMetadata `json:"accountsMeta"` + ExpectedPostings []interpreter.Posting `json:"expectedPostings"` + ActualPostings []interpreter.Posting `json:"actualPostings"` // TODO expected tx meta, accountsMeta } -func Run(program parser.Program, specs Specs) (SpecOutput, error) { +type SpecsResult struct { + // Invariants: total==passing+failing + Total uint `json:"total"` + Passing uint `json:"passing"` + Failing uint `json:"failing"` + Cases []TestCaseResult +} - result, err := interpreter.RunProgram( - context.Background(), - program, - specs.Vars, - interpreter.StaticStore{ - Balances: specs.Balances, - Meta: specs.Meta, - }, nil) +func Run(program parser.Program, specs Specs) SpecsResult { + specsResult := SpecsResult{} - if err != nil { - return SpecOutput{}, err - } + for _, testCase := range specs.TestCases { + // TODO merge balances, vars, meta + meta := specs.Meta + balances := specs.Balances + vars := specs.Vars - success := reflect.DeepEqual(result.Postings, specs.ExpectedPostings) + specsResult.Total += 1 + + result, err := interpreter.RunProgram( + context.Background(), + program, + specs.Vars, + interpreter.StaticStore{ + // TODO merge balance, meta + Meta: meta, + Balances: balances, + }, nil) + + // TODO recover err on missing funds + if err != nil { + panic(err) + } + + pass := reflect.DeepEqual(result.Postings, testCase.ExpectedPostings) + if pass { + specsResult.Passing += 1 + } else { + specsResult.Failing += 1 + } + + specsResult.Cases = append(specsResult.Cases, TestCaseResult{ + It: testCase.It, + Pass: pass, + Meta: meta, + Balances: balances, + Vars: vars, + ExpectedPostings: testCase.ExpectedPostings, + ActualPostings: result.Postings, + }) + } - return SpecOutput{ - It: specs.It, - Success: success, - ExpectedPostings: specs.ExpectedPostings, - ActualPostings: result.Postings, - }, nil + return specsResult } diff --git a/internal/specs_format/parse_test.go b/internal/specs_format/parse_test.go index 28363684..7d50e545 100644 --- a/internal/specs_format/parse_test.go +++ b/internal/specs_format/parse_test.go @@ -13,23 +13,31 @@ import ( func TestParseSpecs(t *testing.T) { raw := ` - { - "it": "d1", - "balances": { - "alice": { "EUR": 200 } - }, - "vars": { - "amt": "200" - }, - "expectedPostings": [ - { - "source": "src", - "destination": "dest", - "asset": "EUR", - "amount": 100 - } - ] - } +{ + "balances": { + "alice": { "EUR": 200 } + }, + "vars": { + "amt": "200" + }, + "testCases": [ + { + "it": "d1", + "balances": { + "bob": { "EUR": 42 } + }, + "expectedPostings": [ + { + "source": "src", + "destination": "dest", + "asset": "EUR", + "amount": 100 + } + ] + } + ] +} + ` var specs specs_format.Specs @@ -37,7 +45,6 @@ func TestParseSpecs(t *testing.T) { require.Nil(t, err) require.Equal(t, specs_format.Specs{ - It: "d1", Balances: interpreter.Balances{ "alice": { "EUR": big.NewInt(200), @@ -46,12 +53,22 @@ func TestParseSpecs(t *testing.T) { Vars: interpreter.VariablesMap{ "amt": "200", }, - ExpectedPostings: []interpreter.Posting{ + TestCases: []specs_format.TestCase{ { - Source: "src", - Destination: "dest", - Asset: "EUR", - Amount: big.NewInt(100), + It: "d1", + Balances: interpreter.Balances{ + "bob": { + "EUR": big.NewInt(42), + }, + }, + ExpectedPostings: []interpreter.Posting{ + { + Source: "src", + Destination: "dest", + Asset: "EUR", + Amount: big.NewInt(100), + }, + }, }, }, }, specs) From 3e9bebaf16d4ef0f748a45e50cb813f41f353799 Mon Sep 17 00:00:00 2001 From: ascandone Date: Fri, 27 Jun 2025 12:54:57 +0200 Subject: [PATCH 04/59] colored diff! --- go.mod | 3 +++ go.sum | 5 ++++- internal/cmd/test.go | 36 ++++++++++++++++++++++++++++++------ 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index 7eaad727..63562cde 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,11 @@ go 1.22.1 require ( github.com/antlr4-go/antlr/v4 v4.13.1 github.com/gkampitakis/go-snaps v0.5.13 + github.com/sergi/go-diff v1.0.0 ) +require gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect + require github.com/goccy/go-yaml v1.18.0 // indirect require ( diff --git a/go.sum b/go.sum index b7cade26..e321ddde 100644 --- a/go.sum +++ b/go.sum @@ -37,6 +37,8 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= @@ -59,7 +61,8 @@ golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cmd/test.go b/internal/cmd/test.go index f5d2a445..f75a32e0 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -4,11 +4,14 @@ import ( "encoding/json" "fmt" "os" + "strings" "github.com/formancehq/numscript/internal/ansi" "github.com/formancehq/numscript/internal/parser" "github.com/formancehq/numscript/internal/specs_format" "github.com/spf13/cobra" + + "github.com/sergi/go-diff/diffmatchpatch" ) func test(path string) { @@ -38,15 +41,36 @@ func test(path string) { out := specs_format.Run(parseResult.Value, specs) for _, result := range out.Cases { if !result.Pass { + expected, _ := json.MarshalIndent(result.ExpectedPostings, "", " ") + actual, _ := json.MarshalIndent(result.ActualPostings, "", " ") + fmt.Println(ansi.Underline(`it: ` + result.It)) - fmt.Println("\nExpected:") - expected, _ := json.MarshalIndent(result.ExpectedPostings, "", " ") - fmt.Println(ansi.ColorGreen(string(expected))) + fmt.Println(ansi.ColorGreen("- Expected")) + fmt.Println(ansi.ColorRed("+ Received\n")) - fmt.Println("\nGot:") - actual, _ := json.MarshalIndent(result.ActualPostings, "", " ") - fmt.Println(ansi.ColorRed(string(actual))) + dmp := diffmatchpatch.New() + + aChars, bChars, lineArray := dmp.DiffLinesToChars(string(expected), string(actual)) + diffs := dmp.DiffMain(aChars, bChars, true) + diffs = dmp.DiffCharsToLines(diffs, lineArray) + + for _, diff := range diffs { + lines := strings.Split(diff.Text, "\n") + for _, line := range lines { + if line == "" { + continue + } + switch diff.Type { + case diffmatchpatch.DiffDelete: + fmt.Println(ansi.ColorGreen("- " + line)) + case diffmatchpatch.DiffInsert: + fmt.Println(ansi.ColorRed("+ " + line)) + case diffmatchpatch.DiffEqual: + fmt.Println(" " + line) + } + } + } } } From 4264a636ce8004d3e9dc381e25a570578c6f4eff Mon Sep 17 00:00:00 2001 From: ascandone Date: Fri, 27 Jun 2025 12:55:21 +0200 Subject: [PATCH 05/59] minor --- internal/cmd/test.go | 62 +++++++++++++++++++++++--------------------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/internal/cmd/test.go b/internal/cmd/test.go index f75a32e0..08d95e3e 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -40,39 +40,41 @@ func test(path string) { out := specs_format.Run(parseResult.Value, specs) for _, result := range out.Cases { - if !result.Pass { - expected, _ := json.MarshalIndent(result.ExpectedPostings, "", " ") - actual, _ := json.MarshalIndent(result.ActualPostings, "", " ") - - fmt.Println(ansi.Underline(`it: ` + result.It)) - - fmt.Println(ansi.ColorGreen("- Expected")) - fmt.Println(ansi.ColorRed("+ Received\n")) - - dmp := diffmatchpatch.New() - - aChars, bChars, lineArray := dmp.DiffLinesToChars(string(expected), string(actual)) - diffs := dmp.DiffMain(aChars, bChars, true) - diffs = dmp.DiffCharsToLines(diffs, lineArray) - - for _, diff := range diffs { - lines := strings.Split(diff.Text, "\n") - for _, line := range lines { - if line == "" { - continue - } - switch diff.Type { - case diffmatchpatch.DiffDelete: - fmt.Println(ansi.ColorGreen("- " + line)) - case diffmatchpatch.DiffInsert: - fmt.Println(ansi.ColorRed("+ " + line)) - case diffmatchpatch.DiffEqual: - fmt.Println(" " + line) - } + if result.Pass { + continue + } + + expected, _ := json.MarshalIndent(result.ExpectedPostings, "", " ") + actual, _ := json.MarshalIndent(result.ActualPostings, "", " ") + + fmt.Println(ansi.Underline(`it: ` + result.It)) + + fmt.Println(ansi.ColorGreen("- Expected")) + fmt.Println(ansi.ColorRed("+ Received\n")) + + dmp := diffmatchpatch.New() + + aChars, bChars, lineArray := dmp.DiffLinesToChars(string(expected), string(actual)) + diffs := dmp.DiffMain(aChars, bChars, true) + diffs = dmp.DiffCharsToLines(diffs, lineArray) + + for _, diff := range diffs { + lines := strings.Split(diff.Text, "\n") + for _, line := range lines { + if line == "" { + continue + } + switch diff.Type { + case diffmatchpatch.DiffDelete: + fmt.Println(ansi.ColorGreen("- " + line)) + case diffmatchpatch.DiffInsert: + fmt.Println(ansi.ColorRed("+ " + line)) + case diffmatchpatch.DiffEqual: + fmt.Println(" " + line) } } - } + } if out.Total == 0 { From 8e7ea3daa092abf8148db19adcd216386ef9703a Mon Sep 17 00:00:00 2001 From: ascandone Date: Fri, 27 Jun 2025 15:51:55 +0200 Subject: [PATCH 06/59] proto --- internal/cmd/test.go | 2 +- internal/interpreter/accounts_metadata.go | 32 +++ internal/interpreter/balances.go | 2 +- internal/interpreter/balances_test.go | 2 +- internal/interpreter/interpreter.go | 2 +- internal/specs_format/index.go | 52 +++- internal/specs_format/run_test.go | 291 ++++++++++++++++++++++ 7 files changed, 370 insertions(+), 13 deletions(-) create mode 100644 internal/interpreter/accounts_metadata.go create mode 100644 internal/specs_format/run_test.go diff --git a/internal/cmd/test.go b/internal/cmd/test.go index 08d95e3e..89995be1 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -38,7 +38,7 @@ func test(path string) { return } - out := specs_format.Run(parseResult.Value, specs) + out := specs_format.Check(parseResult.Value, specs) for _, result := range out.Cases { if result.Pass { continue diff --git a/internal/interpreter/accounts_metadata.go b/internal/interpreter/accounts_metadata.go new file mode 100644 index 00000000..9d8b1f11 --- /dev/null +++ b/internal/interpreter/accounts_metadata.go @@ -0,0 +1,32 @@ +package interpreter + +func (m AccountsMetadata) fetchAccountMetadata(account string) AccountMetadata { + return defaultMapGet(m, account, func() AccountMetadata { + return AccountMetadata{} + }) +} + +func (m AccountsMetadata) DeepClone() AccountsMetadata { + cloned := make(AccountsMetadata) + for account, accountBalances := range m { + for asset, metadataValue := range accountBalances { + clonedAccountBalances := cloned.fetchAccountMetadata(account) + defaultMapGet(clonedAccountBalances, asset, func() string { + return metadataValue + }) + } + } + return cloned +} + +func (m AccountsMetadata) Merge(update AccountsMetadata) { + for acc, accBalances := range update { + cachedAcc := defaultMapGet(m, acc, func() AccountMetadata { + return AccountMetadata{} + }) + + for curr, amt := range accBalances { + cachedAcc[curr] = amt + } + } +} diff --git a/internal/interpreter/balances.go b/internal/interpreter/balances.go index 30505754..dc58515d 100644 --- a/internal/interpreter/balances.go +++ b/internal/interpreter/balances.go @@ -13,7 +13,7 @@ func (b Balances) fetchAccountBalances(account string) AccountBalance { }) } -func (b Balances) deepClone() Balances { +func (b Balances) DeepClone() Balances { cloned := make(Balances) for account, accountBalances := range b { for asset, amount := range accountBalances { diff --git a/internal/interpreter/balances_test.go b/internal/interpreter/balances_test.go index 5fe642a8..a2ad7044 100644 --- a/internal/interpreter/balances_test.go +++ b/internal/interpreter/balances_test.go @@ -41,7 +41,7 @@ func TestCloneBalances(t *testing.T) { }, } - cloned := fullBalance.deepClone() + cloned := fullBalance.DeepClone() fullBalance["alice"]["USD/2"].Set(big.NewInt(42)) diff --git a/internal/interpreter/interpreter.go b/internal/interpreter/interpreter.go index f3670a18..2b074f9f 100644 --- a/internal/interpreter/interpreter.go +++ b/internal/interpreter/interpreter.go @@ -599,7 +599,7 @@ func (s *programState) trySendingToAccount(accountLiteral parser.ValueExpr, amou func (s *programState) cloneState() func() { fsBackup := s.fundsStack.Clone() - balancesBackup := s.CachedBalances.deepClone() + balancesBackup := s.CachedBalances.DeepClone() return func() { s.fundsStack = fsBackup diff --git a/internal/specs_format/index.go b/internal/specs_format/index.go index fce0f7ff..60a145e8 100644 --- a/internal/specs_format/index.go +++ b/internal/specs_format/index.go @@ -45,33 +45,44 @@ type SpecsResult struct { Cases []TestCaseResult } -func Run(program parser.Program, specs Specs) SpecsResult { +func Check(program parser.Program, specs Specs) SpecsResult { specsResult := SpecsResult{} for _, testCase := range specs.TestCases { // TODO merge balances, vars, meta - meta := specs.Meta - balances := specs.Balances - vars := specs.Vars + meta := mergeAccountsMeta(specs.Meta, testCase.Meta) + balances := mergeBalances(specs.Balances, testCase.Balances) + vars := mergeVars(specs.Vars, testCase.Vars) specsResult.Total += 1 result, err := interpreter.RunProgram( context.Background(), program, - specs.Vars, + vars, interpreter.StaticStore{ - // TODO merge balance, meta Meta: meta, Balances: balances, }, nil) + var pass bool + var actualPostings []interpreter.Posting + // TODO recover err on missing funds if err != nil { - panic(err) + if _, ok := err.(interpreter.MissingFundsErr); ok { + + pass = testCase.ExpectedPostings == nil + actualPostings = nil + } else { + panic(err) + } + + } else { + pass = reflect.DeepEqual(result.Postings, testCase.ExpectedPostings) + actualPostings = result.Postings } - pass := reflect.DeepEqual(result.Postings, testCase.ExpectedPostings) if pass { specsResult.Passing += 1 } else { @@ -85,9 +96,32 @@ func Run(program parser.Program, specs Specs) SpecsResult { Balances: balances, Vars: vars, ExpectedPostings: testCase.ExpectedPostings, - ActualPostings: result.Postings, + ActualPostings: actualPostings, }) } return specsResult } + +func mergeVars(v1 interpreter.VariablesMap, v2 interpreter.VariablesMap) interpreter.VariablesMap { + out := interpreter.VariablesMap{} + for k, v := range v1 { + out[k] = v + } + for k, v := range v2 { + out[k] = v + } + return out +} + +func mergeAccountsMeta(m1 interpreter.AccountsMetadata, m2 interpreter.AccountsMetadata) interpreter.AccountsMetadata { + out := m1.DeepClone() + out.Merge(m2) + return out +} + +func mergeBalances(b1 interpreter.Balances, b2 interpreter.Balances) interpreter.Balances { + out := b1.DeepClone() + out.Merge(b2) + return out +} diff --git a/internal/specs_format/run_test.go b/internal/specs_format/run_test.go new file mode 100644 index 00000000..28706ab6 --- /dev/null +++ b/internal/specs_format/run_test.go @@ -0,0 +1,291 @@ +package specs_format_test + +import ( + "encoding/json" + "math/big" + "testing" + + "github.com/formancehq/numscript/internal/interpreter" + "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/specs_format" + "github.com/stretchr/testify/require" +) + +var exampleProgram = parser.Parse(` + vars { + account $source + number $amount + } + + send [USD $amount] ( + source = $source + destination = @dest + ) +`) + +func TestRunSpecsSimple(t *testing.T) { + j := `{ + "testCases": [ + { + "it": "t1", + "vars": { "source": "src", "amount": "42" }, + "balances": { "src": { "USD": 9999 } }, + "expectedPostings": [ + { "source": "src", "destination": "dest", "asset": "USD", "amount": 42 } + ] + } + ] + }` + + var specs specs_format.Specs + err := json.Unmarshal([]byte(j), &specs) + require.Nil(t, err) + + out := specs_format.Check(exampleProgram.Value, specs) + require.Equal(t, specs_format.SpecsResult{ + Total: 1, + Failing: 0, + Passing: 1, + Cases: []specs_format.TestCaseResult{ + { + It: "t1", + Pass: true, + Vars: interpreter.VariablesMap{ + "source": "src", + "amount": "42", + }, + Balances: interpreter.Balances{ + "src": interpreter.AccountBalance{ + "USD": big.NewInt(9999), + }, + }, + Meta: interpreter.AccountsMetadata{}, + ExpectedPostings: []interpreter.Posting{ + { + Source: "src", + Destination: "dest", + Asset: "USD", + Amount: big.NewInt(42), + }, + }, + ActualPostings: []interpreter.Posting{ + { + Source: "src", + Destination: "dest", + Asset: "USD", + Amount: big.NewInt(42), + }, + }, + }, + }, + }, out) + +} + +func TestRunSpecsMergeOuter(t *testing.T) { + j := `{ + "vars": { "source": "src", "amount": "42" }, + "balances": { "src": { "USD": 10 } }, + "testCases": [ + { + "vars": { "amount": "1" }, + "balances": { + "src": { "EUR": 2 }, + "dest": { "USD": 1 } + }, + "it": "t1", + "expectedPostings": [ + { "source": "src", "destination": "dest", "asset": "USD", "amount": 1 } + ] + } + ] + }` + + var specs specs_format.Specs + err := json.Unmarshal([]byte(j), &specs) + require.Nil(t, err) + + out := specs_format.Check(exampleProgram.Value, specs) + require.Equal(t, specs_format.SpecsResult{ + Total: 1, + Failing: 0, + Passing: 1, + Cases: []specs_format.TestCaseResult{ + { + It: "t1", + Pass: true, + Vars: interpreter.VariablesMap{ + "source": "src", + "amount": "1", + }, + Meta: interpreter.AccountsMetadata{}, + Balances: interpreter.Balances{ + "src": interpreter.AccountBalance{ + "USD": big.NewInt(10), + "EUR": big.NewInt(2), + }, + "dest": interpreter.AccountBalance{ + "USD": big.NewInt(1), + }, + }, + ExpectedPostings: []interpreter.Posting{ + { + Source: "src", + Destination: "dest", + Asset: "USD", + Amount: big.NewInt(1), + }, + }, + ActualPostings: []interpreter.Posting{ + { + Source: "src", + Destination: "dest", + Asset: "USD", + Amount: big.NewInt(1), + }, + }, + }, + }, + }, out) + +} + +func TestRunWithMissingBalance(t *testing.T) { + j := `{ + "testCases": [ + { + "it": "t1", + "vars": { "source": "src", "amount": "42" }, + "balances": { "src": { "USD": 1 } }, + "expectedPostings": null + } + ] + }` + + var specs specs_format.Specs + err := json.Unmarshal([]byte(j), &specs) + require.Nil(t, err) + + out := specs_format.Check(exampleProgram.Value, specs) + require.Equal(t, specs_format.SpecsResult{ + Total: 1, + Failing: 0, + Passing: 1, + Cases: []specs_format.TestCaseResult{ + { + It: "t1", + Pass: true, + Vars: interpreter.VariablesMap{ + "source": "src", + "amount": "42", + }, + Balances: interpreter.Balances{ + "src": interpreter.AccountBalance{ + "USD": big.NewInt(1), + }, + }, + Meta: interpreter.AccountsMetadata{}, + ExpectedPostings: nil, + ActualPostings: nil, + }, + }, + }, out) + +} + +func TestRunWithMissingBalanceWhenExpectedPostings(t *testing.T) { + j := `{ + "testCases": [ + { + "it": "t1", + "vars": { "source": "src", "amount": "42" }, + "balances": { "src": { "USD": 1 } }, + "expectedPostings": [ + { "source": "src", "destination": "dest", "asset": "USD", "amount": 1 } + ] + } + ] + }` + + var specs specs_format.Specs + err := json.Unmarshal([]byte(j), &specs) + require.Nil(t, err) + + out := specs_format.Check(exampleProgram.Value, specs) + require.Equal(t, specs_format.SpecsResult{ + Total: 1, + Failing: 1, + Passing: 0, + Cases: []specs_format.TestCaseResult{ + { + It: "t1", + Pass: false, + Vars: interpreter.VariablesMap{ + "source": "src", + "amount": "42", + }, + Balances: interpreter.Balances{ + "src": interpreter.AccountBalance{ + "USD": big.NewInt(1), + }, + }, + Meta: interpreter.AccountsMetadata{}, + ExpectedPostings: []interpreter.Posting{ + { + Source: "src", + Destination: "dest", + Asset: "USD", + Amount: big.NewInt(1), + }, + }, + ActualPostings: nil, + }, + }, + }, out) + +} + +func TestNoPostingsIsNotNullPostings(t *testing.T) { + exampleProgram := parser.Parse(``) + + j := `{ + "testCases": [ + { + "it": "t1", + "vars": { "source": "src", "amount": "42" }, + "balances": { "src": { "USD": 1 } }, + "expectedPostings": null + } + ] + }` + + var specs specs_format.Specs + err := json.Unmarshal([]byte(j), &specs) + require.Nil(t, err) + + out := specs_format.Check(exampleProgram.Value, specs) + require.Equal(t, specs_format.SpecsResult{ + Total: 1, + Failing: 1, + Passing: 0, + Cases: []specs_format.TestCaseResult{ + { + It: "t1", + Pass: false, + Vars: interpreter.VariablesMap{ + "source": "src", + "amount": "42", + }, + Balances: interpreter.Balances{ + "src": interpreter.AccountBalance{ + "USD": big.NewInt(1), + }, + }, + Meta: interpreter.AccountsMetadata{}, + ExpectedPostings: nil, + ActualPostings: []interpreter.Posting{}, + }, + }, + }, out) + +} From db11997a7114db0207a641e59b8e638a48124eec Mon Sep 17 00:00:00 2001 From: ascandone Date: Fri, 27 Jun 2025 16:48:55 +0200 Subject: [PATCH 07/59] impl better UI for balances --- internal/cmd/test.go | 8 ++- .../__snapshots__/balances_test.snap | 7 +++ internal/interpreter/balances.go | 56 +++++++++++++++++++ internal/interpreter/balances_test.go | 15 +++++ 4 files changed, 85 insertions(+), 1 deletion(-) create mode 100755 internal/interpreter/__snapshots__/balances_test.snap diff --git a/internal/cmd/test.go b/internal/cmd/test.go index 89995be1..fbf2cd6a 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -47,7 +47,13 @@ func test(path string) { expected, _ := json.MarshalIndent(result.ExpectedPostings, "", " ") actual, _ := json.MarshalIndent(result.ActualPostings, "", " ") - fmt.Println(ansi.Underline(`it: ` + result.It)) + fmt.Println(ansi.Underline("it: " + result.It)) + + if len(result.Balances) != 0 { + fmt.Println() + fmt.Println(result.Balances.PrettyPrint()) + fmt.Println() + } fmt.Println(ansi.ColorGreen("- Expected")) fmt.Println(ansi.ColorRed("+ Received\n")) diff --git a/internal/interpreter/__snapshots__/balances_test.snap b/internal/interpreter/__snapshots__/balances_test.snap new file mode 100755 index 00000000..220eb761 --- /dev/null +++ b/internal/interpreter/__snapshots__/balances_test.snap @@ -0,0 +1,7 @@ + +[TestPrettyPrintBalance - 1] +| Account | Asset | Balance | +| alice | EUR/2 | 1 | +| alice | USD/1234 | 999999 | +| bob | BTC | 3 | +--- diff --git a/internal/interpreter/balances.go b/internal/interpreter/balances.go index dc58515d..3877b79b 100644 --- a/internal/interpreter/balances.go +++ b/internal/interpreter/balances.go @@ -1,9 +1,11 @@ package interpreter import ( + "fmt" "math/big" "strings" + "github.com/formancehq/numscript/internal/ansi" "github.com/formancehq/numscript/internal/utils" ) @@ -90,3 +92,57 @@ func (b Balances) Merge(update Balances) { } } } + +const accountHeader = "Account" +const assetHeader = "Asset" +const balanceHeader = "Balance" + +func (b Balances) PrettyPrint() string { + type Row struct { + Account string + Asset string + Balance string + } + + var rows []Row + for account, accBalances := range b { + for asset, balance := range accBalances { + rows = append(rows, Row{account, asset, balance.String()}) + } + } + + maxAccountLen := len(accountHeader) + maxAssetLen := len(assetHeader) + maxBalanceLen := len(balanceHeader) + for _, row := range rows { + maxAccountLen = max(maxAccountLen, len(row.Account)) + maxAssetLen = max(maxAssetLen, len(row.Asset)) + maxBalanceLen = max(maxBalanceLen, len(row.Balance)) + } + + out := fmt.Sprintf("| %-*s | %-*s | %-*s |", + maxAccountLen, + ansi.ColorCyan(accountHeader), + + maxAssetLen, + ansi.ColorCyan(assetHeader), + + maxBalanceLen, + ansi.ColorCyan(balanceHeader), + ) + + for _, row := range rows { + out += fmt.Sprintf("\n| %-*s | %-*s | %-*s |", + maxAccountLen, + row.Account, + + maxAssetLen, + row.Asset, + + maxBalanceLen, + row.Balance, + ) + } + + return out +} diff --git a/internal/interpreter/balances_test.go b/internal/interpreter/balances_test.go index a2ad7044..9690b00b 100644 --- a/internal/interpreter/balances_test.go +++ b/internal/interpreter/balances_test.go @@ -4,6 +4,7 @@ import ( "math/big" "testing" + "github.com/gkampitakis/go-snaps/snaps" "github.com/stretchr/testify/require" ) @@ -47,3 +48,17 @@ func TestCloneBalances(t *testing.T) { require.Equal(t, big.NewInt(2), cloned["alice"]["USD/2"]) } + +func TestPrettyPrintBalance(t *testing.T) { + fullBalance := Balances{ + "alice": AccountBalance{ + "EUR/2": big.NewInt(1), + "USD/1234": big.NewInt(999999), + }, + "bob": AccountBalance{ + "BTC": big.NewInt(3), + }, + } + + snaps.MatchSnapshot(t, fullBalance.PrettyPrint()) +} From 9fa0cadd862d8b2c543a9528eee14284b019c5dd Mon Sep 17 00:00:00 2001 From: ascandone Date: Tue, 1 Jul 2025 13:01:01 +0200 Subject: [PATCH 08/59] improve prettyprint function --- internal/interpreter/balances.go | 53 ++--------------- .../utils/__snapshots__/pretty_csv_test.snap | 7 +++ internal/utils/pretty_csv.go | 58 +++++++++++++++++++ internal/utils/pretty_csv_test.go | 20 +++++++ 4 files changed, 90 insertions(+), 48 deletions(-) create mode 100755 internal/utils/__snapshots__/pretty_csv_test.snap create mode 100644 internal/utils/pretty_csv.go create mode 100644 internal/utils/pretty_csv_test.go diff --git a/internal/interpreter/balances.go b/internal/interpreter/balances.go index 3877b79b..8f3fb5c4 100644 --- a/internal/interpreter/balances.go +++ b/internal/interpreter/balances.go @@ -1,11 +1,9 @@ package interpreter import ( - "fmt" "math/big" "strings" - "github.com/formancehq/numscript/internal/ansi" "github.com/formancehq/numscript/internal/utils" ) @@ -93,56 +91,15 @@ func (b Balances) Merge(update Balances) { } } -const accountHeader = "Account" -const assetHeader = "Asset" -const balanceHeader = "Balance" - func (b Balances) PrettyPrint() string { - type Row struct { - Account string - Asset string - Balance string - } + header := []string{"Account", "Asset", "Balance"} - var rows []Row + var rows [][]string for account, accBalances := range b { for asset, balance := range accBalances { - rows = append(rows, Row{account, asset, balance.String()}) + row := []string{account, asset, balance.String()} + rows = append(rows, row) } } - - maxAccountLen := len(accountHeader) - maxAssetLen := len(assetHeader) - maxBalanceLen := len(balanceHeader) - for _, row := range rows { - maxAccountLen = max(maxAccountLen, len(row.Account)) - maxAssetLen = max(maxAssetLen, len(row.Asset)) - maxBalanceLen = max(maxBalanceLen, len(row.Balance)) - } - - out := fmt.Sprintf("| %-*s | %-*s | %-*s |", - maxAccountLen, - ansi.ColorCyan(accountHeader), - - maxAssetLen, - ansi.ColorCyan(assetHeader), - - maxBalanceLen, - ansi.ColorCyan(balanceHeader), - ) - - for _, row := range rows { - out += fmt.Sprintf("\n| %-*s | %-*s | %-*s |", - maxAccountLen, - row.Account, - - maxAssetLen, - row.Asset, - - maxBalanceLen, - row.Balance, - ) - } - - return out + return utils.CsvPretty(header, rows) } diff --git a/internal/utils/__snapshots__/pretty_csv_test.snap b/internal/utils/__snapshots__/pretty_csv_test.snap new file mode 100755 index 00000000..ced59a1c --- /dev/null +++ b/internal/utils/__snapshots__/pretty_csv_test.snap @@ -0,0 +1,7 @@ + +[TestPrettyCsv - 1] +| Account | Asset | Balance | +| alice | EUR/2 | 1 | +| alice | USD/1234 | 999999 | +| bob | BTC | 3 | +--- diff --git a/internal/utils/pretty_csv.go b/internal/utils/pretty_csv.go new file mode 100644 index 00000000..ea4b77a7 --- /dev/null +++ b/internal/utils/pretty_csv.go @@ -0,0 +1,58 @@ +package utils + +import ( + "fmt" + "strings" + + "github.com/formancehq/numscript/internal/ansi" +) + +// Fails if the header is shorter than any of the rows +func CsvPretty( + header []string, + rows [][]string, +) string { + // -- Find paddings + var maxLengths []int = make([]int, len(header)) + for fieldIndex, fieldName := range header { + maxLen := len(fieldName) + + for _, row := range rows { + // panics if row[fieldIndex] is out of bounds + // thus we must never have unlabeled cols + maxLen = max(maxLen, len(row[fieldIndex])) + } + + maxLengths[fieldIndex] = maxLen + } + + var allRows []string + + // -- Print header + { + var partialRow []string + for index, fieldName := range header { + partialRow = append(partialRow, fmt.Sprintf("| %-*s ", + maxLengths[index], + ansi.ColorCyan(fieldName), + )) + } + partialRow = append(partialRow, "|") + allRows = append(allRows, strings.Join(partialRow, "")) + } + + // -- Print rows + for _, row := range rows { + var partialRow []string + for index, fieldName := range row { + partialRow = append(partialRow, fmt.Sprintf("| %-*s ", + maxLengths[index], + fieldName, + )) + } + partialRow = append(partialRow, "|") + allRows = append(allRows, strings.Join(partialRow, "")) + } + + return strings.Join(allRows, "\n") +} diff --git a/internal/utils/pretty_csv_test.go b/internal/utils/pretty_csv_test.go new file mode 100644 index 00000000..636c8fa2 --- /dev/null +++ b/internal/utils/pretty_csv_test.go @@ -0,0 +1,20 @@ +package utils_test + +import ( + "testing" + + "github.com/formancehq/numscript/internal/utils" + "github.com/gkampitakis/go-snaps/snaps" +) + +func TestPrettyCsv(t *testing.T) { + out := utils.CsvPretty([]string{ + "Account", "Asset", "Balance", + }, [][]string{ + {"alice", "EUR/2", "1"}, + {"alice", "USD/1234", "999999"}, + {"bob", "BTC", "3"}, + }) + + snaps.MatchSnapshot(t, out) +} From 8a9abffb963d179936a1f8c44e6a05ee09eb9076 Mon Sep 17 00:00:00 2001 From: ascandone Date: Tue, 1 Jul 2025 13:32:08 +0200 Subject: [PATCH 09/59] improve pprint --- internal/cmd/test.go | 13 ++++++++++ internal/interpreter/accounts_metadata.go | 18 +++++++++++++ internal/interpreter/balances.go | 2 +- .../utils/__snapshots__/pretty_csv_test.snap | 7 ++++++ internal/utils/pretty_csv.go | 25 +++++++++++++++++++ internal/utils/pretty_csv_test.go | 10 ++++++++ 6 files changed, 74 insertions(+), 1 deletion(-) diff --git a/internal/cmd/test.go b/internal/cmd/test.go index fbf2cd6a..af1dcfa0 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -9,6 +9,7 @@ import ( "github.com/formancehq/numscript/internal/ansi" "github.com/formancehq/numscript/internal/parser" "github.com/formancehq/numscript/internal/specs_format" + "github.com/formancehq/numscript/internal/utils" "github.com/spf13/cobra" "github.com/sergi/go-diff/diffmatchpatch" @@ -55,6 +56,18 @@ func test(path string) { fmt.Println() } + if len(result.Meta) != 0 { + fmt.Println() + fmt.Println(result.Meta.PrettyPrint()) + fmt.Println() + } + + if len(result.Vars) != 0 { + fmt.Println() + fmt.Println(utils.CsvPrettyMap("Name", "Value", result.Vars)) + fmt.Println() + } + fmt.Println(ansi.ColorGreen("- Expected")) fmt.Println(ansi.ColorRed("+ Received\n")) diff --git a/internal/interpreter/accounts_metadata.go b/internal/interpreter/accounts_metadata.go index 9d8b1f11..81e206d9 100644 --- a/internal/interpreter/accounts_metadata.go +++ b/internal/interpreter/accounts_metadata.go @@ -1,5 +1,9 @@ package interpreter +import ( + "github.com/formancehq/numscript/internal/utils" +) + func (m AccountsMetadata) fetchAccountMetadata(account string) AccountMetadata { return defaultMapGet(m, account, func() AccountMetadata { return AccountMetadata{} @@ -30,3 +34,17 @@ func (m AccountsMetadata) Merge(update AccountsMetadata) { } } } + +func (m AccountsMetadata) PrettyPrint() string { + header := []string{"Account", "Name", "Value"} + + var rows [][]string + for account, accMetadata := range m { + for name, value := range accMetadata { + row := []string{account, name, value} + rows = append(rows, row) + } + } + + return utils.CsvPretty(header, rows, true) +} diff --git a/internal/interpreter/balances.go b/internal/interpreter/balances.go index 8f3fb5c4..fa38cada 100644 --- a/internal/interpreter/balances.go +++ b/internal/interpreter/balances.go @@ -101,5 +101,5 @@ func (b Balances) PrettyPrint() string { rows = append(rows, row) } } - return utils.CsvPretty(header, rows) + return utils.CsvPretty(header, rows, true) } diff --git a/internal/utils/__snapshots__/pretty_csv_test.snap b/internal/utils/__snapshots__/pretty_csv_test.snap index ced59a1c..aea68443 100755 --- a/internal/utils/__snapshots__/pretty_csv_test.snap +++ b/internal/utils/__snapshots__/pretty_csv_test.snap @@ -5,3 +5,10 @@ | alice | USD/1234 | 999999 | | bob | BTC | 3 | --- + +[TestPrettyCsvMap - 1] +| Name | Value | +| a | 0 | +| b | 12345 | +| very-very-very-long-key | | +--- diff --git a/internal/utils/pretty_csv.go b/internal/utils/pretty_csv.go index ea4b77a7..41710f03 100644 --- a/internal/utils/pretty_csv.go +++ b/internal/utils/pretty_csv.go @@ -2,6 +2,7 @@ package utils import ( "fmt" + "slices" "strings" "github.com/formancehq/numscript/internal/ansi" @@ -11,7 +12,22 @@ import ( func CsvPretty( header []string, rows [][]string, + sortRows bool, ) string { + if sortRows { + slices.SortStableFunc(rows, func(x, y []string) int { + strX := strings.Join(x, "|") + strY := strings.Join(y, "|") + if strX == strY { + return 0 + } else if strX < strY { + return -1 + } else { + return 1 + } + }) + } + // -- Find paddings var maxLengths []int = make([]int, len(header)) for fieldIndex, fieldName := range header { @@ -56,3 +72,12 @@ func CsvPretty( return strings.Join(allRows, "\n") } + +func CsvPrettyMap(keyName string, valueName string, m map[string]string) string { + var rows [][]string + for k, v := range m { + rows = append(rows, []string{k, v}) + } + + return CsvPretty([]string{keyName, valueName}, rows, true) +} diff --git a/internal/utils/pretty_csv_test.go b/internal/utils/pretty_csv_test.go index 636c8fa2..010456b7 100644 --- a/internal/utils/pretty_csv_test.go +++ b/internal/utils/pretty_csv_test.go @@ -14,6 +14,16 @@ func TestPrettyCsv(t *testing.T) { {"alice", "EUR/2", "1"}, {"alice", "USD/1234", "999999"}, {"bob", "BTC", "3"}, + }, true) + + snaps.MatchSnapshot(t, out) +} + +func TestPrettyCsvMap(t *testing.T) { + out := utils.CsvPrettyMap("Name", "Value", map[string]string{ + "a": "0", + "b": "12345", + "very-very-very-long-key": "", }) snaps.MatchSnapshot(t, out) From d4218a8d2faf21a26389a5b3fcc0d16d06e815e1 Mon Sep 17 00:00:00 2001 From: ascandone Date: Tue, 1 Jul 2025 14:12:16 +0200 Subject: [PATCH 10/59] improve ui --- internal/ansi/ansi.go | 36 +++++++++++++++++++++++++++++ internal/cmd/test.go | 15 +++++++++--- internal/interpreter/interpreter.go | 9 ++++++++ 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/internal/ansi/ansi.go b/internal/ansi/ansi.go index c68bf300..ea04057a 100644 --- a/internal/ansi/ansi.go +++ b/internal/ansi/ansi.go @@ -4,6 +4,15 @@ import "fmt" const resetCol = "\033[0m" +func Compose(cols ...func(string) string) func(string) string { + return func(s string) string { + for _, mod := range cols { + s = mod(s) + } + return s + } +} + func col(s string, code int) string { c := fmt.Sprintf("\033[%dm", code) return c + s + resetCol @@ -13,6 +22,10 @@ func ColorRed(s string) string { return col(s, 31) } +func ColorWhite(s string) string { + return col(s, 37) +} + func ColorGreen(s string) string { return col(s, 32) } @@ -25,6 +38,29 @@ func ColorCyan(s string) string { return col(s, 36) } +func ColorLight(s string) string { + return col(s, 97) // Bright white → light +} + +// BG +func BgDark(s string) string { + return col(s, 100) +} + +func BgRed(s string) string { + return col(s, 41) +} + +func BgGreen(s string) string { + return col(s, 42) +} + +// modifiers + +func Bold(s string) string { + return col(s, 1) +} + func Underline(s string) string { return col(s, 4) } diff --git a/internal/cmd/test.go b/internal/cmd/test.go index af1dcfa0..865fdb68 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -45,10 +45,14 @@ func test(path string) { continue } - expected, _ := json.MarshalIndent(result.ExpectedPostings, "", " ") - actual, _ := json.MarshalIndent(result.ActualPostings, "", " ") + failColor := ansi.Compose(ansi.BgRed, ansi.ColorLight, ansi.Bold) + fmt.Print(failColor(" FAIL ")) + fmt.Println(ansi.ColorRed(" " + path + " > " + result.It)) - fmt.Println(ansi.Underline("it: " + result.It)) + showGiven := len(result.Balances) != 0 || len(result.Meta) != 0 || len(result.Vars) != 0 + if showGiven { + fmt.Println(ansi.Underline("\nGIVEN:")) + } if len(result.Balances) != 0 { fmt.Println() @@ -68,11 +72,16 @@ func test(path string) { fmt.Println() } + fmt.Print(ansi.Underline("EXPECT:\n\n")) + fmt.Println(ansi.ColorGreen("- Expected")) fmt.Println(ansi.ColorRed("+ Received\n")) dmp := diffmatchpatch.New() + expected, _ := json.MarshalIndent(result.ExpectedPostings, "", " ") + actual, _ := json.MarshalIndent(result.ActualPostings, "", " ") + aChars, bChars, lineArray := dmp.DiffLinesToChars(string(expected), string(actual)) diffs := dmp.DiffMain(aChars, bChars, true) diffs = dmp.DiffCharsToLines(diffs, lineArray) diff --git a/internal/interpreter/interpreter.go b/internal/interpreter/interpreter.go index 2b074f9f..6a51a0e8 100644 --- a/internal/interpreter/interpreter.go +++ b/internal/interpreter/interpreter.go @@ -989,3 +989,12 @@ func CalculateSafeWithdraw( safe := CalculateMaxSafeWithdraw(balance, overdraft) return utils.MinBigInt(safe, requestedAmount) } + +func PrettyPrintPostings(postings []Posting) string { + var rows [][]string + for _, posting := range postings { + row := []string{posting.Source, posting.Destination, posting.Asset, posting.Amount.String()} + rows = append(rows, row) + } + return utils.CsvPretty([]string{"Source", "Destination", "Asset", "Amount"}, rows, false) +} From 434e8ecb7e21ce30cacc2ed0e08edc7f75ef82f0 Mon Sep 17 00:00:00 2001 From: ascandone Date: Tue, 1 Jul 2025 14:18:08 +0200 Subject: [PATCH 11/59] improve run cmd ui --- internal/cmd/run.go | 22 ++++++---------------- internal/interpreter/interpreter.go | 9 +++++++++ 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 1c7434da..13341b0a 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -8,7 +8,6 @@ import ( "os" "strings" - "github.com/formancehq/numscript/internal/ansi" "github.com/formancehq/numscript/internal/flags" "github.com/formancehq/numscript/internal/interpreter" "github.com/formancehq/numscript/internal/parser" @@ -171,23 +170,14 @@ func showJson(result *interpreter.ExecutionResult) error { return err } -func showPretty(result *interpreter.ExecutionResult) error { - fmt.Println(ansi.ColorCyan("Postings:")) - postingsJson, err := json.MarshalIndent(result.Postings, "", " ") - if err != nil { - return fmt.Errorf("error marshaling postings: %w", err) - } - fmt.Println(string(postingsJson)) - - fmt.Println() +func showPretty(result *interpreter.ExecutionResult) { + fmt.Println("Postings:") + fmt.Println(interpreter.PrettyPrintPostings(result.Postings)) - fmt.Println(ansi.ColorCyan("Meta:")) - txMetaJson, err := json.MarshalIndent(result.Metadata, "", " ") - if err != nil { - return fmt.Errorf("error marshaling metadata: %w", err) + if len(result.Metadata) != 0 { + fmt.Println("Meta:") + fmt.Println(interpreter.PrettyPrintMeta(result.Metadata)) } - fmt.Println(string(txMetaJson)) - return nil } func getRunCmd() *cobra.Command { diff --git a/internal/interpreter/interpreter.go b/internal/interpreter/interpreter.go index 6a51a0e8..e484bf22 100644 --- a/internal/interpreter/interpreter.go +++ b/internal/interpreter/interpreter.go @@ -998,3 +998,12 @@ func PrettyPrintPostings(postings []Posting) string { } return utils.CsvPretty([]string{"Source", "Destination", "Asset", "Amount"}, rows, false) } + +func PrettyPrintMeta(meta Metadata) string { + m := map[string]string{} + for k, v := range meta { + m[k] = v.String() + } + + return utils.CsvPrettyMap("Name", "Value", m) +} From 9d324b7d00ed0211d8a251cd17cf573dc2c68af9 Mon Sep 17 00:00:00 2001 From: ascandone Date: Tue, 1 Jul 2025 19:24:40 +0200 Subject: [PATCH 12/59] improve UI --- internal/ansi/ansi.go | 23 ++++- internal/cmd/test.go | 213 +++++++++++++++++++++++++++--------------- 2 files changed, 159 insertions(+), 77 deletions(-) diff --git a/internal/ansi/ansi.go b/internal/ansi/ansi.go index ea04057a..822bdf31 100644 --- a/internal/ansi/ansi.go +++ b/internal/ansi/ansi.go @@ -1,6 +1,9 @@ package ansi -import "fmt" +import ( + "fmt" + "strings" +) const resetCol = "\033[0m" @@ -13,9 +16,19 @@ func Compose(cols ...func(string) string) func(string) string { } } +func replaceLast(s, oldStr, newStr string) string { + lastIndex := strings.LastIndex(s, oldStr) + if lastIndex == -1 { + return s + } + return s[:lastIndex] + newStr + s[lastIndex+len(oldStr):] +} + func col(s string, code int) string { - c := fmt.Sprintf("\033[%dm", code) - return c + s + resetCol + colorCode := fmt.Sprintf("\033[%dm", code) + // This trick should allow to stack colors (TODO test) + s = replaceLast(s, resetCol, resetCol+colorCode) + return colorCode + s + resetCol } func ColorRed(s string) string { @@ -42,6 +55,10 @@ func ColorLight(s string) string { return col(s, 97) // Bright white → light } +func ColorBrightBlack(s string) string { + return col(s, 90) +} + // BG func BgDark(s string) string { return col(s, 100) diff --git a/internal/cmd/test.go b/internal/cmd/test.go index 865fdb68..e6121b34 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" "strings" "github.com/formancehq/numscript/internal/ansi" @@ -15,115 +16,179 @@ import ( "github.com/sergi/go-diff/diffmatchpatch" ) -func test(path string) { - numscriptContent, err := os.ReadFile(path) +func showFailingTestCase(specsFilePath string, result specs_format.TestCaseResult) { + if result.Pass { + return + } + + fmt.Print("\n\n") + + failColor := ansi.Compose(ansi.BgRed, ansi.ColorLight, ansi.Bold) + fmt.Print(failColor(" FAIL ")) + fmt.Println(ansi.ColorRed(" " + specsFilePath + " > " + result.It)) + + showGiven := len(result.Balances) != 0 || len(result.Meta) != 0 || len(result.Vars) != 0 + if showGiven { + fmt.Println(ansi.Underline("\nGIVEN:")) + } + + if len(result.Balances) != 0 { + fmt.Println() + fmt.Println(result.Balances.PrettyPrint()) + fmt.Println() + } + + if len(result.Meta) != 0 { + fmt.Println() + fmt.Println(result.Meta.PrettyPrint()) + fmt.Println() + } + + if len(result.Vars) != 0 { + fmt.Println() + fmt.Println(utils.CsvPrettyMap("Name", "Value", result.Vars)) + fmt.Println() + } + + fmt.Print(ansi.Underline("EXPECT:\n\n")) + + fmt.Println(ansi.ColorGreen("- Expected")) + fmt.Println(ansi.ColorRed("+ Received\n")) + + dmp := diffmatchpatch.New() + + expected, _ := json.MarshalIndent(result.ExpectedPostings, "", " ") + actual, _ := json.MarshalIndent(result.ActualPostings, "", " ") + + aChars, bChars, lineArray := dmp.DiffLinesToChars(string(expected), string(actual)) + diffs := dmp.DiffMain(aChars, bChars, true) + diffs = dmp.DiffCharsToLines(diffs, lineArray) + + for _, diff := range diffs { + lines := strings.Split(diff.Text, "\n") + for _, line := range lines { + if line == "" { + continue + } + switch diff.Type { + case diffmatchpatch.DiffDelete: + fmt.Println(ansi.ColorGreen("- " + line)) + case diffmatchpatch.DiffInsert: + fmt.Println(ansi.ColorRed("+ " + line)) + case diffmatchpatch.DiffEqual: + fmt.Println(" " + line) + } + } + } + +} + +func test(specsFilePath string) specs_format.SpecsResult { + if !strings.HasSuffix(specsFilePath, ".num.specs.json") { + panic("Wrong name") + } + + numscriptFileName := strings.TrimSuffix(specsFilePath, ".specs.json") + + numscriptContent, err := os.ReadFile(numscriptFileName) if err != nil { os.Stderr.Write([]byte(err.Error())) - return + os.Exit(1) } parseResult := parser.Parse(string(numscriptContent)) // TODO assert no parse err // TODO we might want to do static checking - specsFileContent, err := os.ReadFile(path + ".specs.json") + specsFileContent, err := os.ReadFile(specsFilePath) if err != nil { os.Stderr.Write([]byte(err.Error())) - return + os.Exit(1) } var specs specs_format.Specs err = json.Unmarshal([]byte(specsFileContent), &specs) if err != nil { os.Stderr.Write([]byte(err.Error())) - return + os.Exit(1) } out := specs_format.Check(parseResult.Value, specs) - for _, result := range out.Cases { - if result.Pass { - continue - } - failColor := ansi.Compose(ansi.BgRed, ansi.ColorLight, ansi.Bold) - fmt.Print(failColor(" FAIL ")) - fmt.Println(ansi.ColorRed(" " + path + " > " + result.It)) + if out.Total == 0 { + fmt.Println(ansi.ColorRed("Empty test suite!")) + os.Exit(1) + } else if out.Failing == 0 { + testsCount := ansi.ColorBrightBlack(fmt.Sprintf("(%d tests)", out.Total)) + fmt.Printf("%s %s %s\n", ansi.ColorGreen("✓"), numscriptFileName, testsCount) + } else { + failedTestsCount := ansi.ColorRed(fmt.Sprintf("%d failed", out.Failing)) + + testsCount := ansi.ColorBrightBlack(fmt.Sprintf("(%d tests | %s)", out.Total, failedTestsCount)) + fmt.Printf("%s %s %s\n", ansi.ColorRed("❯"), numscriptFileName, testsCount) - showGiven := len(result.Balances) != 0 || len(result.Meta) != 0 || len(result.Vars) != 0 - if showGiven { - fmt.Println(ansi.Underline("\nGIVEN:")) - } + for _, result := range out.Cases { + if result.Pass { + continue + } - if len(result.Balances) != 0 { - fmt.Println() - fmt.Println(result.Balances.PrettyPrint()) - fmt.Println() + fmt.Printf(" %s %s\n", ansi.ColorRed("×"), result.It) } - if len(result.Meta) != 0 { - fmt.Println() - fmt.Println(result.Meta.PrettyPrint()) - fmt.Println() - } + } + + return out +} - if len(result.Vars) != 0 { - fmt.Println() - fmt.Println(utils.CsvPrettyMap("Name", "Value", result.Vars)) - fmt.Println() +var testCmd = &cobra.Command{ + Use: "test ", + Short: "Test a numscript file, using the corresponding spec file", + Args: cobra.MatchAll(), + Run: func(cmd *cobra.Command, paths []string) { + if len(paths) == 0 { + paths = []string{"."} } - fmt.Print(ansi.Underline("EXPECT:\n\n")) + for _, path := range paths { + path = strings.TrimSuffix(path, "/") - fmt.Println(ansi.ColorGreen("- Expected")) - fmt.Println(ansi.ColorRed("+ Received\n")) + glob := fmt.Sprintf(path + "/*.num.specs.json") - dmp := diffmatchpatch.New() + files, err := filepath.Glob(glob) + if err != nil { + panic(err) + } - expected, _ := json.MarshalIndent(result.ExpectedPostings, "", " ") - actual, _ := json.MarshalIndent(result.ActualPostings, "", " ") + type FailingSpec struct { + File string + Result specs_format.TestCaseResult + } - aChars, bChars, lineArray := dmp.DiffLinesToChars(string(expected), string(actual)) - diffs := dmp.DiffMain(aChars, bChars, true) - diffs = dmp.DiffCharsToLines(diffs, lineArray) + var failingTests []FailingSpec - for _, diff := range diffs { - lines := strings.Split(diff.Text, "\n") - for _, line := range lines { - if line == "" { - continue - } - switch diff.Type { - case diffmatchpatch.DiffDelete: - fmt.Println(ansi.ColorGreen("- " + line)) - case diffmatchpatch.DiffInsert: - fmt.Println(ansi.ColorRed("+ " + line)) - case diffmatchpatch.DiffEqual: - fmt.Println(" " + line) - } - } - } + for _, file := range files { + out := test(file) - } + for _, testCase := range out.Cases { + if testCase.Pass { + continue + } - if out.Total == 0 { - fmt.Println(ansi.ColorRed("Empty test suite!")) - os.Exit(1) - } else if out.Failing == 0 { - fmt.Printf("All tests passing ✅\n") - return - } else { - os.Exit(1) - } + failingTests = append(failingTests, FailingSpec{ + File: file, + Result: testCase, + }) + } + } -} + if len(failingTests) == 0 { + return + } -// TODO test directory instead -var testCmd = &cobra.Command{ - Use: "test ", - Short: "Test a numscript file, using the corresponding spec file", - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - path := args[0] - test(path) + for _, failedTest := range failingTests { + showFailingTestCase(failedTest.File, failedTest.Result) + } + os.Exit(1) + } }, } From a2c4f96adbcf9cc21018d274851210de84de3a4b Mon Sep 17 00:00:00 2001 From: ascandone Date: Tue, 1 Jul 2025 19:32:56 +0200 Subject: [PATCH 13/59] refactor --- internal/cmd/test.go | 82 +++++++++++++++++++++++--------------------- 1 file changed, 43 insertions(+), 39 deletions(-) diff --git a/internal/cmd/test.go b/internal/cmd/test.go index e6121b34..3216e686 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -140,55 +140,59 @@ func test(specsFilePath string) specs_format.SpecsResult { return out } -var testCmd = &cobra.Command{ - Use: "test ", - Short: "Test a numscript file, using the corresponding spec file", - Args: cobra.MatchAll(), - Run: func(cmd *cobra.Command, paths []string) { - if len(paths) == 0 { - paths = []string{"."} - } +func testPaths(paths []string) { + for _, path := range paths { + path = strings.TrimSuffix(path, "/") - for _, path := range paths { - path = strings.TrimSuffix(path, "/") + glob := fmt.Sprintf(path + "/*.num.specs.json") - glob := fmt.Sprintf(path + "/*.num.specs.json") - - files, err := filepath.Glob(glob) - if err != nil { - panic(err) - } - - type FailingSpec struct { - File string - Result specs_format.TestCaseResult - } + files, err := filepath.Glob(glob) + if err != nil { + panic(err) + } - var failingTests []FailingSpec + type FailingSpec struct { + File string + Result specs_format.TestCaseResult + } - for _, file := range files { - out := test(file) + var failingTests []FailingSpec - for _, testCase := range out.Cases { - if testCase.Pass { - continue - } + for _, file := range files { + out := test(file) - failingTests = append(failingTests, FailingSpec{ - File: file, - Result: testCase, - }) + for _, testCase := range out.Cases { + if testCase.Pass { + continue } - } - if len(failingTests) == 0 { - return + failingTests = append(failingTests, FailingSpec{ + File: file, + Result: testCase, + }) } + } - for _, failedTest := range failingTests { - showFailingTestCase(failedTest.File, failedTest.Result) - } - os.Exit(1) + if len(failingTests) == 0 { + return + } + + for _, failedTest := range failingTests { + showFailingTestCase(failedTest.File, failedTest.Result) + } + os.Exit(1) + } +} + +var testCmd = &cobra.Command{ + Use: "test ", + Short: "Test a numscript file, using the corresponding spec file", + Args: cobra.MatchAll(), + Run: func(cmd *cobra.Command, paths []string) { + if len(paths) == 0 { + paths = []string{"."} } + + testPaths(paths) }, } From f6983ae3b8915976613a755119d9acaaec4d3255 Mon Sep 17 00:00:00 2001 From: ascandone Date: Tue, 1 Jul 2025 20:56:20 +0200 Subject: [PATCH 14/59] edit col --- internal/cmd/test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/test.go b/internal/cmd/test.go index 3216e686..e6150b2f 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -76,7 +76,7 @@ func showFailingTestCase(specsFilePath string, result specs_format.TestCaseResul case diffmatchpatch.DiffInsert: fmt.Println(ansi.ColorRed("+ " + line)) case diffmatchpatch.DiffEqual: - fmt.Println(" " + line) + fmt.Println(ansi.ColorBrightBlack(" " + line)) } } } From 0e625ab04d4b8fa5d298de3bc93c6a8d8b024671 Mon Sep 17 00:00:00 2001 From: ascandone Date: Tue, 1 Jul 2025 21:52:41 +0200 Subject: [PATCH 15/59] show files stats --- internal/ansi/ansi.go | 8 +++ internal/cmd/test.go | 115 +++++++++++++++++++++++++++++++++++------- 2 files changed, 106 insertions(+), 17 deletions(-) diff --git a/internal/ansi/ansi.go b/internal/ansi/ansi.go index 822bdf31..df0cc096 100644 --- a/internal/ansi/ansi.go +++ b/internal/ansi/ansi.go @@ -59,6 +59,14 @@ func ColorBrightBlack(s string) string { return col(s, 90) } +func ColorBrightRed(s string) string { + return col(s, 91) +} + +func ColorBrightGreen(s string) string { + return col(s, 92) +} + // BG func BgDark(s string) string { return col(s, 100) diff --git a/internal/cmd/test.go b/internal/cmd/test.go index e6150b2f..02b0abee 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "strings" "github.com/formancehq/numscript/internal/ansi" @@ -140,7 +141,16 @@ func test(specsFilePath string) specs_format.SpecsResult { return out } +type testResult struct { + File string + Result specs_format.TestCaseResult +} + func testPaths(paths []string) { + testFiles := 0 + failedTestFiles := 0 + + var allTests []testResult for _, path := range paths { path = strings.TrimSuffix(path, "/") @@ -150,38 +160,109 @@ func testPaths(paths []string) { if err != nil { panic(err) } - - type FailingSpec struct { - File string - Result specs_format.TestCaseResult - } - - var failingTests []FailingSpec + testFiles += len(files) for _, file := range files { out := test(file) for _, testCase := range out.Cases { - if testCase.Pass { - continue - } - - failingTests = append(failingTests, FailingSpec{ + allTests = append(allTests, testResult{ File: file, Result: testCase, }) } + + // Count tests + isTestFailed := slices.ContainsFunc(out.Cases, func(tc specs_format.TestCaseResult) bool { + return tc.Pass + }) + if isTestFailed { + failedTestFiles += 1 + } } + } + + for _, test_ := range allTests { + showFailingTestCase(test_.File, test_.Result) + } + + // Stats + printFilesStats(allTests) + +} + +func printFilesStats(allTests []testResult) { + failedTests := utils.Filter(allTests, func(t testResult) bool { + return !t.Result.Pass + }) - if len(failingTests) == 0 { - return + testFilesLabel := "Test files" + testsLabel := "Tests" + + paddedLabel := func(s string) string { + maxLen := max(len(testFilesLabel), len(testsLabel)) // yeah, ok, this could be hardcoded, I know + return ansi.ColorBrightBlack(fmt.Sprintf(" %*s ", maxLen, s)) + } + + fmt.Println() + + // Files stats + { + filesCount := len(slices.CompactFunc(allTests, func(t1 testResult, t2 testResult) bool { + return t1.File == t2.File + })) + failedTestsFilesCount := len(slices.CompactFunc(failedTests, func(t1 testResult, t2 testResult) bool { + return t1.File == t2.File + })) + passedTestsFilesCount := filesCount - failedTestsFilesCount + + var testFilesUIParts []string + if failedTestsFilesCount != 0 { + testFilesUIParts = append(testFilesUIParts, + ansi.Compose(ansi.ColorBrightRed, ansi.Bold)(fmt.Sprintf("%d failed", failedTestsFilesCount)), + ) + } + if passedTestsFilesCount != 0 { + testFilesUIParts = append(testFilesUIParts, + ansi.Compose(ansi.ColorBrightGreen, ansi.Bold)(fmt.Sprintf("%d passed", passedTestsFilesCount)), + ) } + testFilesUI := strings.Join(testFilesUIParts, ansi.ColorBrightBlack(" | ")) + totalTestFilesUI := ansi.ColorBrightBlack(fmt.Sprintf("(%d)", filesCount)) + fmt.Print(paddedLabel(testFilesLabel) + " " + testFilesUI + " " + totalTestFilesUI) + } + + fmt.Println() + + // Tests stats + { + + testsCount := len(allTests) + failedTestsCount := len(failedTests) + passedTestsCount := testsCount - failedTestsCount - for _, failedTest := range failingTests { - showFailingTestCase(failedTest.File, failedTest.Result) + var testUIParts []string + if failedTestsCount != 0 { + testUIParts = append(testUIParts, + ansi.Compose(ansi.ColorBrightRed, ansi.Bold)(fmt.Sprintf("%d failed", failedTestsCount)), + ) + } + if passedTestsCount != 0 { + testUIParts = append(testUIParts, + ansi.Compose(ansi.ColorBrightGreen, ansi.Bold)(fmt.Sprintf("%d passed", passedTestsCount)), + ) + } + + testsUI := strings.Join(testUIParts, ansi.ColorBrightBlack(" | ")) + totalTestsUI := ansi.ColorBrightBlack(fmt.Sprintf("(%d)", testsCount)) + + fmt.Print(paddedLabel(testsLabel) + " " + testsUI + " " + totalTestsUI) + + if failedTestsCount != 0 { + os.Exit(1) } - os.Exit(1) } + } var testCmd = &cobra.Command{ From 9d5df762f6ba5306269adc93ac00c1da801b88ae Mon Sep 17 00:00:00 2001 From: ascandone Date: Wed, 2 Jul 2025 13:57:07 +0200 Subject: [PATCH 16/59] improve schema --- internal/specs_format/index.go | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/internal/specs_format/index.go b/internal/specs_format/index.go index 60a145e8..64cc8b82 100644 --- a/internal/specs_format/index.go +++ b/internal/specs_format/index.go @@ -17,24 +17,28 @@ type Specs struct { } type TestCase struct { - It string `json:"it"` - Balances interpreter.Balances `json:"balances,omitempty"` - Vars interpreter.VariablesMap `json:"vars,omitempty"` - Meta interpreter.AccountsMetadata `json:"accountsMeta,omitempty"` - ExpectedPostings []interpreter.Posting `json:"expectedPostings"` - // TODO expected tx meta, accountsMeta + It string `json:"it"` + Balances interpreter.Balances `json:"balances,omitempty"` + Vars interpreter.VariablesMap `json:"vars,omitempty"` + Meta interpreter.AccountsMetadata `json:"accountsMeta,omitempty"` + ExpectedPostings []interpreter.Posting `json:"expectedPostings"` + ExpectedTxMeta *interpreter.AccountsMetadata `json:"expectedTxMeta,omitempty"` + ExpectedAccountsMeta *map[string]string `json:"expectedAccountsMeta,omitempty"` + ExpectMissingFunds bool `json:"expectMissingFunds,omitempty"` } type TestCaseResult struct { - It string `json:"it"` - Pass bool `json:"pass"` - Balances interpreter.Balances `json:"balances"` - Vars interpreter.VariablesMap `json:"vars"` - Meta interpreter.AccountsMetadata `json:"accountsMeta"` - ExpectedPostings []interpreter.Posting `json:"expectedPostings"` - ActualPostings []interpreter.Posting `json:"actualPostings"` - - // TODO expected tx meta, accountsMeta + It string `json:"it"` + Pass bool `json:"pass"` + Balances interpreter.Balances `json:"balances"` + Vars interpreter.VariablesMap `json:"vars"` + Meta interpreter.AccountsMetadata `json:"accountsMeta"` + ExpectedPostings []interpreter.Posting `json:"expectedPostings"` + ActualPostings []interpreter.Posting `json:"actualPostings"` + ExpectedTxMeta *map[string]string `json:"expectedTxMeta,omitempty"` + ActualTxMeta *map[string]string `json:"actualTxMeta,omitempty"` + ExpectedAccountsMeta *interpreter.AccountsMetadata `json:"expectedAccountsMeta,omitempty"` + ActualAccountsMeta *interpreter.AccountsMetadata `json:"actualAccountsMeta,omitempty"` } type SpecsResult struct { From b6631bdbb5e3e67b27f387cfc96ff50731e5e256 Mon Sep 17 00:00:00 2001 From: ascandone Date: Wed, 2 Jul 2025 13:58:57 +0200 Subject: [PATCH 17/59] change field name --- internal/specs_format/index.go | 8 ++++---- internal/specs_format/parse_test.go | 2 +- internal/specs_format/run_test.go | 10 +++++----- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/specs_format/index.go b/internal/specs_format/index.go index 64cc8b82..8a8f7299 100644 --- a/internal/specs_format/index.go +++ b/internal/specs_format/index.go @@ -21,10 +21,10 @@ type TestCase struct { Balances interpreter.Balances `json:"balances,omitempty"` Vars interpreter.VariablesMap `json:"vars,omitempty"` Meta interpreter.AccountsMetadata `json:"accountsMeta,omitempty"` - ExpectedPostings []interpreter.Posting `json:"expectedPostings"` - ExpectedTxMeta *interpreter.AccountsMetadata `json:"expectedTxMeta,omitempty"` - ExpectedAccountsMeta *map[string]string `json:"expectedAccountsMeta,omitempty"` - ExpectMissingFunds bool `json:"expectMissingFunds,omitempty"` + ExpectedPostings []interpreter.Posting `json:"expect.postings"` + ExpectedTxMeta *interpreter.AccountsMetadata `json:"expect.txMeta,omitempty"` + ExpectedAccountsMeta *map[string]string `json:"expect.accountsMeta,omitempty"` + ExpectMissingFunds bool `json:"expect.missingFunds,omitempty"` } type TestCaseResult struct { diff --git a/internal/specs_format/parse_test.go b/internal/specs_format/parse_test.go index 7d50e545..3caaddb9 100644 --- a/internal/specs_format/parse_test.go +++ b/internal/specs_format/parse_test.go @@ -26,7 +26,7 @@ func TestParseSpecs(t *testing.T) { "balances": { "bob": { "EUR": 42 } }, - "expectedPostings": [ + "expect.postings": [ { "source": "src", "destination": "dest", diff --git a/internal/specs_format/run_test.go b/internal/specs_format/run_test.go index 28706ab6..a5bfc786 100644 --- a/internal/specs_format/run_test.go +++ b/internal/specs_format/run_test.go @@ -30,7 +30,7 @@ func TestRunSpecsSimple(t *testing.T) { "it": "t1", "vars": { "source": "src", "amount": "42" }, "balances": { "src": { "USD": 9999 } }, - "expectedPostings": [ + "expect.postings": [ { "source": "src", "destination": "dest", "asset": "USD", "amount": 42 } ] } @@ -94,7 +94,7 @@ func TestRunSpecsMergeOuter(t *testing.T) { "dest": { "USD": 1 } }, "it": "t1", - "expectedPostings": [ + "expect.postings": [ { "source": "src", "destination": "dest", "asset": "USD", "amount": 1 } ] } @@ -157,7 +157,7 @@ func TestRunWithMissingBalance(t *testing.T) { "it": "t1", "vars": { "source": "src", "amount": "42" }, "balances": { "src": { "USD": 1 } }, - "expectedPostings": null + "expect.postings": null } ] }` @@ -200,7 +200,7 @@ func TestRunWithMissingBalanceWhenExpectedPostings(t *testing.T) { "it": "t1", "vars": { "source": "src", "amount": "42" }, "balances": { "src": { "USD": 1 } }, - "expectedPostings": [ + "expect.postings": [ { "source": "src", "destination": "dest", "asset": "USD", "amount": 1 } ] } @@ -254,7 +254,7 @@ func TestNoPostingsIsNotNullPostings(t *testing.T) { "it": "t1", "vars": { "source": "src", "amount": "42" }, "balances": { "src": { "USD": 1 } }, - "expectedPostings": null + "expect.postings": null } ] }` From 7ab8d472cc74406dc4bdaed07486bbbf0aa68b33 Mon Sep 17 00:00:00 2001 From: ascandone Date: Wed, 2 Jul 2025 13:59:04 +0200 Subject: [PATCH 18/59] add col --- internal/ansi/ansi.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/ansi/ansi.go b/internal/ansi/ansi.go index df0cc096..81227cbe 100644 --- a/internal/ansi/ansi.go +++ b/internal/ansi/ansi.go @@ -67,6 +67,10 @@ func ColorBrightGreen(s string) string { return col(s, 92) } +func ColorBrightYellow(s string) string { + return col(s, 93) +} + // BG func BgDark(s string) string { return col(s, 100) From 6e26269cc7b975d93a1c4067a4c1353b3778da47 Mon Sep 17 00:00:00 2001 From: ascandone Date: Wed, 2 Jul 2025 13:59:10 +0200 Subject: [PATCH 19/59] add util --- internal/utils/utils.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 8a7bc7b4..53cab30c 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -56,3 +56,12 @@ func Filter[T any](slice []T, predicate func(x T) bool) []T { } return ret } + +func Map[T any, U any](slice []T, f func(x T) U) []U { + // TODO make + var ret []U + for _, x := range slice { + ret = append(ret, f(x)) + } + return ret +} From ab69c69f69a1124bd0888c07e00f65e5071be5d9 Mon Sep 17 00:00:00 2001 From: ascandone Date: Wed, 2 Jul 2025 14:02:54 +0200 Subject: [PATCH 20/59] add interactive mode mvp --- internal/cmd/test.go | 70 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 64 insertions(+), 6 deletions(-) diff --git a/internal/cmd/test.go b/internal/cmd/test.go index 02b0abee..0aef432b 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -1,6 +1,7 @@ package cmd import ( + "bufio" "encoding/json" "fmt" "os" @@ -17,9 +18,12 @@ import ( "github.com/sergi/go-diff/diffmatchpatch" ) -func showFailingTestCase(specsFilePath string, result specs_format.TestCaseResult) { +func showFailingTestCase(testResult testResult) (rerun bool) { + specsFilePath := testResult.File + result := testResult.Result + if result.Pass { - return + return false } fmt.Print("\n\n") @@ -82,9 +86,54 @@ func showFailingTestCase(specsFilePath string, result specs_format.TestCaseResul } } + if interactiveMode { + fmt.Println(ansi.ColorBrightBlack( + fmt.Sprintf("\nPress %s to update snapshot, %s to go the the next one", + ansi.ColorBrightYellow("u"), + ansi.ColorBrightYellow("n"), + ))) + + reader := bufio.NewReader(os.Stdin) + line, _, err := reader.ReadLine() + if err != nil { + panic(err) + } + + switch string(line) { + case "u": + testResult.Specs.TestCases = utils.Map(testResult.Specs.TestCases, func(t specs_format.TestCase) specs_format.TestCase { + // TODO check there are no duplicate "It" + if t.It == testResult.Result.It { + t.ExpectedPostings = testResult.Result.ActualPostings + } + + return t + }) + + newSpecs, err := json.MarshalIndent(testResult.Specs, "", " ") + if err != nil { + panic(err) + } + + err = os.WriteFile(testResult.File, newSpecs, os.ModePerm) + if err != nil { + panic(err) + } + return true + + case "n": + return false + + default: + panic("TODO invalid command") + } + + } + + return false } -func test(specsFilePath string) specs_format.SpecsResult { +func test(specsFilePath string) (specs_format.Specs, specs_format.SpecsResult) { if !strings.HasSuffix(specsFilePath, ".num.specs.json") { panic("Wrong name") } @@ -138,10 +187,11 @@ func test(specsFilePath string) specs_format.SpecsResult { } - return out + return specs, out } type testResult struct { + Specs specs_format.Specs File string Result specs_format.TestCaseResult } @@ -163,10 +213,11 @@ func testPaths(paths []string) { testFiles += len(files) for _, file := range files { - out := test(file) + specs, out := test(file) for _, testCase := range out.Cases { allTests = append(allTests, testResult{ + Specs: specs, File: file, Result: testCase, }) @@ -183,7 +234,12 @@ func testPaths(paths []string) { } for _, test_ := range allTests { - showFailingTestCase(test_.File, test_.Result) + rerun := showFailingTestCase(test_) + if rerun { + fmt.Print("\033[H\033[2J") + testPaths(paths) + return + } } // Stats @@ -191,6 +247,8 @@ func testPaths(paths []string) { } +var interactiveMode = false + func printFilesStats(allTests []testResult) { failedTests := utils.Filter(allTests, func(t testResult) bool { return !t.Result.Pass From 59c01ebddb70084539f79c609c90519f7020aba5 Mon Sep 17 00:00:00 2001 From: ascandone Date: Wed, 2 Jul 2025 17:35:16 +0200 Subject: [PATCH 21/59] allow feature flags --- internal/specs_format/index.go | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/internal/specs_format/index.go b/internal/specs_format/index.go index 8a8f7299..3ad1529b 100644 --- a/internal/specs_format/index.go +++ b/internal/specs_format/index.go @@ -10,10 +10,11 @@ import ( // --- Specs: type Specs struct { - Balances interpreter.Balances `json:"balances,omitempty"` - Vars interpreter.VariablesMap `json:"vars,omitempty"` - Meta interpreter.AccountsMetadata `json:"accountsMeta,omitempty"` - TestCases []TestCase `json:"testCases,omitempty"` + FeatureFlags []string `json:"featureFlags,omitempty"` + Balances interpreter.Balances `json:"balances,omitempty"` + Vars interpreter.VariablesMap `json:"vars,omitempty"` + Meta interpreter.AccountsMetadata `json:"accountsMeta,omitempty"` + TestCases []TestCase `json:"testCases,omitempty"` } type TestCase struct { @@ -60,6 +61,11 @@ func Check(program parser.Program, specs Specs) SpecsResult { specsResult.Total += 1 + featureFlags := make(map[string]struct{}) + for _, flag := range specs.FeatureFlags { + featureFlags[flag] = struct{}{} + } + result, err := interpreter.RunProgram( context.Background(), program, @@ -67,7 +73,9 @@ func Check(program parser.Program, specs Specs) SpecsResult { interpreter.StaticStore{ Meta: meta, Balances: balances, - }, nil) + }, + featureFlags, + ) var pass bool var actualPostings []interpreter.Posting From 81c6eb217979e3a5e6a5d801f313d99f275c8062 Mon Sep 17 00:00:00 2001 From: ascandone Date: Mon, 7 Jul 2025 17:55:47 +0200 Subject: [PATCH 22/59] refactor assertions --- internal/cmd/test.go | 124 +++++++++++++++++------------- internal/specs_format/index.go | 112 ++++++++++++++++++++------- internal/specs_format/run_test.go | 109 ++++++++++++++------------ 3 files changed, 212 insertions(+), 133 deletions(-) diff --git a/internal/cmd/test.go b/internal/cmd/test.go index 0aef432b..55d645c7 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/formancehq/numscript/internal/ansi" + "github.com/formancehq/numscript/internal/interpreter" "github.com/formancehq/numscript/internal/parser" "github.com/formancehq/numscript/internal/specs_format" "github.com/formancehq/numscript/internal/utils" @@ -18,6 +19,34 @@ import ( "github.com/sergi/go-diff/diffmatchpatch" ) +func showDiff(expected_ any, got_ any) { + dmp := diffmatchpatch.New() + + expected, _ := json.MarshalIndent(expected_, "", " ") + actual, _ := json.MarshalIndent(got_, "", " ") + + aChars, bChars, lineArray := dmp.DiffLinesToChars(string(expected), string(actual)) + diffs := dmp.DiffMain(aChars, bChars, true) + diffs = dmp.DiffCharsToLines(diffs, lineArray) + + for _, diff := range diffs { + lines := strings.Split(diff.Text, "\n") + for _, line := range lines { + if line == "" { + continue + } + switch diff.Type { + case diffmatchpatch.DiffDelete: + fmt.Println(ansi.ColorGreen("- " + line)) + case diffmatchpatch.DiffInsert: + fmt.Println(ansi.ColorRed("+ " + line)) + case diffmatchpatch.DiffEqual: + fmt.Println(ansi.ColorBrightBlack(" " + line)) + } + } + } +} + func showFailingTestCase(testResult testResult) (rerun bool) { specsFilePath := testResult.File result := testResult.Result @@ -60,72 +89,59 @@ func showFailingTestCase(testResult testResult) (rerun bool) { fmt.Println(ansi.ColorGreen("- Expected")) fmt.Println(ansi.ColorRed("+ Received\n")) - dmp := diffmatchpatch.New() + for _, failedAssertion := range result.FailedAssertions { + showDiff(failedAssertion.Expected, failedAssertion.Got) - expected, _ := json.MarshalIndent(result.ExpectedPostings, "", " ") - actual, _ := json.MarshalIndent(result.ActualPostings, "", " ") + if interactiveMode { + fmt.Println(ansi.ColorBrightBlack( + fmt.Sprintf("\nPress %s to update snapshot, %s to go the the next one", + ansi.ColorBrightYellow("u"), + ansi.ColorBrightYellow("n"), + ))) - aChars, bChars, lineArray := dmp.DiffLinesToChars(string(expected), string(actual)) - diffs := dmp.DiffMain(aChars, bChars, true) - diffs = dmp.DiffCharsToLines(diffs, lineArray) - - for _, diff := range diffs { - lines := strings.Split(diff.Text, "\n") - for _, line := range lines { - if line == "" { - continue - } - switch diff.Type { - case diffmatchpatch.DiffDelete: - fmt.Println(ansi.ColorGreen("- " + line)) - case diffmatchpatch.DiffInsert: - fmt.Println(ansi.ColorRed("+ " + line)) - case diffmatchpatch.DiffEqual: - fmt.Println(ansi.ColorBrightBlack(" " + line)) + reader := bufio.NewReader(os.Stdin) + line, _, err := reader.ReadLine() + if err != nil { + panic(err) } - } - } - if interactiveMode { - fmt.Println(ansi.ColorBrightBlack( - fmt.Sprintf("\nPress %s to update snapshot, %s to go the the next one", - ansi.ColorBrightYellow("u"), - ansi.ColorBrightYellow("n"), - ))) + switch string(line) { + case "u": + testResult.Specs.TestCases = utils.Map(testResult.Specs.TestCases, func(t specs_format.TestCase) specs_format.TestCase { + // TODO check there are no duplicate "It" + if t.It == testResult.Result.It { + switch failedAssertion.Expected { + case "expect.postings": + t.ExpectedPostings = failedAssertion.Expected.([]interpreter.Posting) - reader := bufio.NewReader(os.Stdin) - line, _, err := reader.ReadLine() - if err != nil { - panic(err) - } + default: + panic("TODO implement") + + } - switch string(line) { - case "u": - testResult.Specs.TestCases = utils.Map(testResult.Specs.TestCases, func(t specs_format.TestCase) specs_format.TestCase { - // TODO check there are no duplicate "It" - if t.It == testResult.Result.It { - t.ExpectedPostings = testResult.Result.ActualPostings + } + + return t + }) + + newSpecs, err := json.MarshalIndent(testResult.Specs, "", " ") + if err != nil { + panic(err) } - return t - }) + err = os.WriteFile(testResult.File, newSpecs, os.ModePerm) + if err != nil { + panic(err) + } + return true - newSpecs, err := json.MarshalIndent(testResult.Specs, "", " ") - if err != nil { - panic(err) - } + case "n": + return false - err = os.WriteFile(testResult.File, newSpecs, os.ModePerm) - if err != nil { - panic(err) + default: + panic("TODO invalid command") } - return true - - case "n": - return false - default: - panic("TODO invalid command") } } diff --git a/internal/specs_format/index.go b/internal/specs_format/index.go index 3ad1529b..fef763bc 100644 --- a/internal/specs_format/index.go +++ b/internal/specs_format/index.go @@ -18,28 +18,27 @@ type Specs struct { } type TestCase struct { - It string `json:"it"` - Balances interpreter.Balances `json:"balances,omitempty"` - Vars interpreter.VariablesMap `json:"vars,omitempty"` - Meta interpreter.AccountsMetadata `json:"accountsMeta,omitempty"` - ExpectedPostings []interpreter.Posting `json:"expect.postings"` - ExpectedTxMeta *interpreter.AccountsMetadata `json:"expect.txMeta,omitempty"` - ExpectedAccountsMeta *map[string]string `json:"expect.accountsMeta,omitempty"` - ExpectMissingFunds bool `json:"expect.missingFunds,omitempty"` + It string `json:"it"` + Balances interpreter.Balances `json:"balances,omitempty"` + Vars interpreter.VariablesMap `json:"vars,omitempty"` + Meta interpreter.AccountsMetadata `json:"accountsMeta,omitempty"` + + // Expectations + ExpectedPostings []interpreter.Posting `json:"expect.postings"` + ExpectedTxMeta map[string]string `json:"expect.txMeta,omitempty"` + ExpectedAccountsMeta interpreter.AccountsMetadata `json:"expect.accountsMeta,omitempty"` + ExpectMissingFunds bool `json:"expect.missingFunds,omitempty"` } type TestCaseResult struct { - It string `json:"it"` - Pass bool `json:"pass"` - Balances interpreter.Balances `json:"balances"` - Vars interpreter.VariablesMap `json:"vars"` - Meta interpreter.AccountsMetadata `json:"accountsMeta"` - ExpectedPostings []interpreter.Posting `json:"expectedPostings"` - ActualPostings []interpreter.Posting `json:"actualPostings"` - ExpectedTxMeta *map[string]string `json:"expectedTxMeta,omitempty"` - ActualTxMeta *map[string]string `json:"actualTxMeta,omitempty"` - ExpectedAccountsMeta *interpreter.AccountsMetadata `json:"expectedAccountsMeta,omitempty"` - ActualAccountsMeta *interpreter.AccountsMetadata `json:"actualAccountsMeta,omitempty"` + It string `json:"it"` + Pass bool `json:"pass"` + Balances interpreter.Balances `json:"balances"` + Vars interpreter.VariablesMap `json:"vars"` + Meta interpreter.AccountsMetadata `json:"accountsMeta"` + + // Assertions + FailedAssertions []AssertionMismatch[any] `json:"failedAssertions"` } type SpecsResult struct { @@ -50,6 +49,19 @@ type SpecsResult struct { Cases []TestCaseResult } +func runAssertion(failedAssertions []AssertionMismatch[any], assertion string, expected any, got any) []AssertionMismatch[any] { + eq := reflect.DeepEqual(expected, got) + if !eq { + return append(failedAssertions, AssertionMismatch[any]{ + Assertion: assertion, + Expected: expected, + Got: got, + }) + } + + return failedAssertions +} + func Check(program parser.Program, specs Specs) SpecsResult { specsResult := SpecsResult{} @@ -77,24 +89,63 @@ func Check(program parser.Program, specs Specs) SpecsResult { featureFlags, ) - var pass bool - var actualPostings []interpreter.Posting + var failedAssertions []AssertionMismatch[any] // TODO recover err on missing funds if err != nil { if _, ok := err.(interpreter.MissingFundsErr); ok { - - pass = testCase.ExpectedPostings == nil - actualPostings = nil + if !testCase.ExpectMissingFunds { + failedAssertions = append(failedAssertions, AssertionMismatch[any]{ + Assertion: "expect.missingFunds", + Expected: false, + Got: true, + }) + } } else { panic(err) } } else { - pass = reflect.DeepEqual(result.Postings, testCase.ExpectedPostings) - actualPostings = result.Postings + + if testCase.ExpectMissingFunds { + failedAssertions = append(failedAssertions, AssertionMismatch[any]{ + Assertion: "expect.missingFunds", + Expected: true, + Got: false, + }) + } + + if testCase.ExpectedPostings != nil { + failedAssertions = runAssertion(failedAssertions, + "expect.postings", + testCase.ExpectedPostings, + result.Postings, + ) + } + + if testCase.ExpectedTxMeta != nil { + metadata := map[string]string{} + for k, v := range result.Metadata { + metadata[k] = v.String() + } + failedAssertions = runAssertion(failedAssertions, + "expect.txMeta", + testCase.ExpectedTxMeta, + metadata, + ) + } + + if testCase.ExpectedAccountsMeta != nil { + failedAssertions = runAssertion(failedAssertions, + "expect.accountsMeta", + testCase.ExpectedAccountsMeta, + result.AccountsMetadata, + ) + } + } + pass := len(failedAssertions) == 0 if pass { specsResult.Passing += 1 } else { @@ -107,8 +158,7 @@ func Check(program parser.Program, specs Specs) SpecsResult { Meta: meta, Balances: balances, Vars: vars, - ExpectedPostings: testCase.ExpectedPostings, - ActualPostings: actualPostings, + FailedAssertions: failedAssertions, }) } @@ -137,3 +187,9 @@ func mergeBalances(b1 interpreter.Balances, b2 interpreter.Balances) interpreter out.Merge(b2) return out } + +type AssertionMismatch[T any] struct { + Assertion string `json:"assertion"` + Expected T `json:"expected,omitempty"` + Got T `json:"got,omitempty"` +} diff --git a/internal/specs_format/run_test.go b/internal/specs_format/run_test.go index a5bfc786..016b97a5 100644 --- a/internal/specs_format/run_test.go +++ b/internal/specs_format/run_test.go @@ -59,23 +59,24 @@ func TestRunSpecsSimple(t *testing.T) { "USD": big.NewInt(9999), }, }, - Meta: interpreter.AccountsMetadata{}, - ExpectedPostings: []interpreter.Posting{ - { - Source: "src", - Destination: "dest", - Asset: "USD", - Amount: big.NewInt(42), - }, - }, - ActualPostings: []interpreter.Posting{ - { - Source: "src", - Destination: "dest", - Asset: "USD", - Amount: big.NewInt(42), - }, - }, + Meta: interpreter.AccountsMetadata{}, + FailedAssertions: nil, + // ExpectedPostings: []interpreter.Posting{ + // { + // Source: "src", + // Destination: "dest", + // Asset: "USD", + // Amount: big.NewInt(42), + // }, + // }, + // ActualPostings: []interpreter.Posting{ + // { + // Source: "src", + // Destination: "dest", + // Asset: "USD", + // Amount: big.NewInt(42), + // }, + // }, }, }, }, out) @@ -128,22 +129,23 @@ func TestRunSpecsMergeOuter(t *testing.T) { "USD": big.NewInt(1), }, }, - ExpectedPostings: []interpreter.Posting{ - { - Source: "src", - Destination: "dest", - Asset: "USD", - Amount: big.NewInt(1), - }, - }, - ActualPostings: []interpreter.Posting{ - { - Source: "src", - Destination: "dest", - Asset: "USD", - Amount: big.NewInt(1), - }, - }, + FailedAssertions: nil, + // ExpectedPostings: []interpreter.Posting{ + // { + // Source: "src", + // Destination: "dest", + // Asset: "USD", + // Amount: big.NewInt(1), + // }, + // }, + // ActualPostings: []interpreter.Posting{ + // { + // Source: "src", + // Destination: "dest", + // Asset: "USD", + // Amount: big.NewInt(1), + // }, + // }, }, }, }, out) @@ -157,6 +159,7 @@ func TestRunWithMissingBalance(t *testing.T) { "it": "t1", "vars": { "source": "src", "amount": "42" }, "balances": { "src": { "USD": 1 } }, + "expect.missingFunds": false, "expect.postings": null } ] @@ -169,12 +172,12 @@ func TestRunWithMissingBalance(t *testing.T) { out := specs_format.Check(exampleProgram.Value, specs) require.Equal(t, specs_format.SpecsResult{ Total: 1, - Failing: 0, - Passing: 1, + Failing: 1, + Passing: 0, Cases: []specs_format.TestCaseResult{ { It: "t1", - Pass: true, + Pass: false, Vars: interpreter.VariablesMap{ "source": "src", "amount": "42", @@ -184,9 +187,16 @@ func TestRunWithMissingBalance(t *testing.T) { "USD": big.NewInt(1), }, }, - Meta: interpreter.AccountsMetadata{}, - ExpectedPostings: nil, - ActualPostings: nil, + Meta: interpreter.AccountsMetadata{}, + FailedAssertions: []specs_format.AssertionMismatch[any]{ + { + Assertion: "expect.missingFunds", + Expected: false, + Got: true, + }, + }, + // ExpectedPostings: nil, + // ActualPostings: nil, }, }, }, out) @@ -230,22 +240,20 @@ func TestRunWithMissingBalanceWhenExpectedPostings(t *testing.T) { }, }, Meta: interpreter.AccountsMetadata{}, - ExpectedPostings: []interpreter.Posting{ + FailedAssertions: []specs_format.AssertionMismatch[any]{ { - Source: "src", - Destination: "dest", - Asset: "USD", - Amount: big.NewInt(1), + Assertion: "expect.missingFunds", + Got: true, + Expected: false, }, }, - ActualPostings: nil, }, }, }, out) } -func TestNoPostingsIsNotNullPostings(t *testing.T) { +func TestNullPostingsIsNoop(t *testing.T) { exampleProgram := parser.Parse(``) j := `{ @@ -266,12 +274,12 @@ func TestNoPostingsIsNotNullPostings(t *testing.T) { out := specs_format.Check(exampleProgram.Value, specs) require.Equal(t, specs_format.SpecsResult{ Total: 1, - Failing: 1, - Passing: 0, + Failing: 0, + Passing: 1, Cases: []specs_format.TestCaseResult{ { It: "t1", - Pass: false, + Pass: true, Vars: interpreter.VariablesMap{ "source": "src", "amount": "42", @@ -282,8 +290,7 @@ func TestNoPostingsIsNotNullPostings(t *testing.T) { }, }, Meta: interpreter.AccountsMetadata{}, - ExpectedPostings: nil, - ActualPostings: []interpreter.Posting{}, + FailedAssertions: nil, }, }, }, out) From c6e87affc26e7cf437d31ca56b61f2b2bc54fc34 Mon Sep 17 00:00:00 2001 From: ascandone Date: Tue, 8 Jul 2025 13:08:03 +0200 Subject: [PATCH 23/59] show failed assertion's name --- internal/cmd/test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/cmd/test.go b/internal/cmd/test.go index 55d645c7..2f084387 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -84,12 +84,14 @@ func showFailingTestCase(testResult testResult) (rerun bool) { fmt.Println() } - fmt.Print(ansi.Underline("EXPECT:\n\n")) - + fmt.Println() fmt.Println(ansi.ColorGreen("- Expected")) fmt.Println(ansi.ColorRed("+ Received\n")) for _, failedAssertion := range result.FailedAssertions { + + fmt.Println(ansi.Underline(failedAssertion.Assertion)) + fmt.Println() showDiff(failedAssertion.Expected, failedAssertion.Got) if interactiveMode { From 7f1c691643bf94e60dcd7934df21e5df04892307 Mon Sep 17 00:00:00 2001 From: ascandone Date: Tue, 8 Jul 2025 20:09:07 +0200 Subject: [PATCH 24/59] better handling of panic --- internal/cmd/test.go | 16 +++++++++++++++- internal/specs_format/index.go | 25 +++++++++++++------------ internal/specs_format/run_test.go | 20 +++++++++++++++----- 3 files changed, 43 insertions(+), 18 deletions(-) diff --git a/internal/cmd/test.go b/internal/cmd/test.go index 2f084387..0cf2bc13 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -181,7 +181,21 @@ func test(specsFilePath string) (specs_format.Specs, specs_format.SpecsResult) { os.Exit(1) } - out := specs_format.Check(parseResult.Value, specs) + out, iErr := specs_format.Check(parseResult.Value, specs) + if iErr != nil { + rng := iErr.GetRange() + + errFile := fmt.Sprintf("\nError: %s:%d:%d\n\n", numscriptFileName, rng.Start.Line+1, rng.Start.Character+1) + + os.Stderr.Write([]byte(ansi.ColorRed(errFile))) + + os.Stderr.Write([]byte(iErr.Error())) + if rng.Start != rng.End { + os.Stderr.Write([]byte("\n")) + os.Stderr.Write([]byte(iErr.GetRange().ShowOnSource(parseResult.Source))) + } + os.Exit(1) + } if out.Total == 0 { fmt.Println(ansi.ColorRed("Empty test suite!")) diff --git a/internal/specs_format/index.go b/internal/specs_format/index.go index fef763bc..a6178d47 100644 --- a/internal/specs_format/index.go +++ b/internal/specs_format/index.go @@ -62,7 +62,7 @@ func runAssertion(failedAssertions []AssertionMismatch[any], assertion string, e return failedAssertions } -func Check(program parser.Program, specs Specs) SpecsResult { +func Check(program parser.Program, specs Specs) (SpecsResult, interpreter.InterpreterError) { specsResult := SpecsResult{} for _, testCase := range specs.TestCases { @@ -93,16 +93,17 @@ func Check(program parser.Program, specs Specs) SpecsResult { // TODO recover err on missing funds if err != nil { - if _, ok := err.(interpreter.MissingFundsErr); ok { - if !testCase.ExpectMissingFunds { - failedAssertions = append(failedAssertions, AssertionMismatch[any]{ - Assertion: "expect.missingFunds", - Expected: false, - Got: true, - }) - } - } else { - panic(err) + _, ok := err.(interpreter.MissingFundsErr) + if !ok { + return SpecsResult{}, err + } + + if !testCase.ExpectMissingFunds { + failedAssertions = append(failedAssertions, AssertionMismatch[any]{ + Assertion: "expect.missingFunds", + Expected: false, + Got: true, + }) } } else { @@ -162,7 +163,7 @@ func Check(program parser.Program, specs Specs) SpecsResult { }) } - return specsResult + return specsResult, nil } func mergeVars(v1 interpreter.VariablesMap, v2 interpreter.VariablesMap) interpreter.VariablesMap { diff --git a/internal/specs_format/run_test.go b/internal/specs_format/run_test.go index 016b97a5..4a4e060b 100644 --- a/internal/specs_format/run_test.go +++ b/internal/specs_format/run_test.go @@ -41,7 +41,9 @@ func TestRunSpecsSimple(t *testing.T) { err := json.Unmarshal([]byte(j), &specs) require.Nil(t, err) - out := specs_format.Check(exampleProgram.Value, specs) + out, err := specs_format.Check(exampleProgram.Value, specs) + require.Nil(t, err) + require.Equal(t, specs_format.SpecsResult{ Total: 1, Failing: 0, @@ -106,7 +108,9 @@ func TestRunSpecsMergeOuter(t *testing.T) { err := json.Unmarshal([]byte(j), &specs) require.Nil(t, err) - out := specs_format.Check(exampleProgram.Value, specs) + out, err := specs_format.Check(exampleProgram.Value, specs) + require.Nil(t, err) + require.Equal(t, specs_format.SpecsResult{ Total: 1, Failing: 0, @@ -169,7 +173,9 @@ func TestRunWithMissingBalance(t *testing.T) { err := json.Unmarshal([]byte(j), &specs) require.Nil(t, err) - out := specs_format.Check(exampleProgram.Value, specs) + out, err := specs_format.Check(exampleProgram.Value, specs) + require.Nil(t, err) + require.Equal(t, specs_format.SpecsResult{ Total: 1, Failing: 1, @@ -221,7 +227,9 @@ func TestRunWithMissingBalanceWhenExpectedPostings(t *testing.T) { err := json.Unmarshal([]byte(j), &specs) require.Nil(t, err) - out := specs_format.Check(exampleProgram.Value, specs) + out, err := specs_format.Check(exampleProgram.Value, specs) + require.Nil(t, err) + require.Equal(t, specs_format.SpecsResult{ Total: 1, Failing: 1, @@ -271,7 +279,9 @@ func TestNullPostingsIsNoop(t *testing.T) { err := json.Unmarshal([]byte(j), &specs) require.Nil(t, err) - out := specs_format.Check(exampleProgram.Value, specs) + out, err := specs_format.Check(exampleProgram.Value, specs) + require.Nil(t, err) + require.Equal(t, specs_format.SpecsResult{ Total: 1, Failing: 0, From b6d06240d6b246ad3745ea6a5c800a970ff8c840 Mon Sep 17 00:00:00 2001 From: ascandone Date: Tue, 8 Jul 2025 20:13:33 +0200 Subject: [PATCH 25/59] refactor --- internal/cmd/test.go | 108 +++++++++++++++++++++++-------------------- 1 file changed, 58 insertions(+), 50 deletions(-) diff --git a/internal/cmd/test.go b/internal/cmd/test.go index 0cf2bc13..07227eb0 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -47,6 +47,61 @@ func showDiff(expected_ any, got_ any) { } } +func fixSnapshot(testResult testResult, failedAssertion specs_format.AssertionMismatch[any]) bool { + if !interactiveMode { + return false + } + + fmt.Println(ansi.ColorBrightBlack( + fmt.Sprintf("\nPress %s to update snapshot, %s to go the the next one", + ansi.ColorBrightYellow("u"), + ansi.ColorBrightYellow("n"), + ))) + + reader := bufio.NewReader(os.Stdin) + line, _, err := reader.ReadLine() + if err != nil { + panic(err) + } + + switch string(line) { + case "u": + testResult.Specs.TestCases = utils.Map(testResult.Specs.TestCases, func(t specs_format.TestCase) specs_format.TestCase { + // TODO check there are no duplicate "It" + if t.It == testResult.Result.It { + switch failedAssertion.Expected { + case "expect.postings": + t.ExpectedPostings = failedAssertion.Expected.([]interpreter.Posting) + + default: + panic("TODO implement") + + } + + } + + return t + }) + + newSpecs, err := json.MarshalIndent(testResult.Specs, "", " ") + if err != nil { + panic(err) + } + + err = os.WriteFile(testResult.File, newSpecs, os.ModePerm) + if err != nil { + panic(err) + } + return true + + case "n": + return false + + default: + panic("TODO invalid command") + } +} + func showFailingTestCase(testResult testResult) (rerun bool) { specsFilePath := testResult.File result := testResult.Result @@ -94,56 +149,9 @@ func showFailingTestCase(testResult testResult) (rerun bool) { fmt.Println() showDiff(failedAssertion.Expected, failedAssertion.Got) - if interactiveMode { - fmt.Println(ansi.ColorBrightBlack( - fmt.Sprintf("\nPress %s to update snapshot, %s to go the the next one", - ansi.ColorBrightYellow("u"), - ansi.ColorBrightYellow("n"), - ))) - - reader := bufio.NewReader(os.Stdin) - line, _, err := reader.ReadLine() - if err != nil { - panic(err) - } - - switch string(line) { - case "u": - testResult.Specs.TestCases = utils.Map(testResult.Specs.TestCases, func(t specs_format.TestCase) specs_format.TestCase { - // TODO check there are no duplicate "It" - if t.It == testResult.Result.It { - switch failedAssertion.Expected { - case "expect.postings": - t.ExpectedPostings = failedAssertion.Expected.([]interpreter.Posting) - - default: - panic("TODO implement") - - } - - } - - return t - }) - - newSpecs, err := json.MarshalIndent(testResult.Specs, "", " ") - if err != nil { - panic(err) - } - - err = os.WriteFile(testResult.File, newSpecs, os.ModePerm) - if err != nil { - panic(err) - } - return true - - case "n": - return false - - default: - panic("TODO invalid command") - } - + rerun := fixSnapshot(testResult, failedAssertion) + if rerun { + return true } } From f862e7716dc7f4c242c8b84164bd2c1a28a14e49 Mon Sep 17 00:00:00 2001 From: ascandone Date: Tue, 8 Jul 2025 21:19:57 +0200 Subject: [PATCH 26/59] improve err --- internal/cmd/test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/test.go b/internal/cmd/test.go index 07227eb0..ddaec18c 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -206,7 +206,7 @@ func test(specsFilePath string) (specs_format.Specs, specs_format.SpecsResult) { } if out.Total == 0 { - fmt.Println(ansi.ColorRed("Empty test suite!")) + fmt.Println(ansi.ColorRed("Empty test suite: " + specsFilePath)) os.Exit(1) } else if out.Failing == 0 { testsCount := ansi.ColorBrightBlack(fmt.Sprintf("(%d tests)", out.Total)) From ef9fcf12c4cc379efcf4252216b3082f66e6f5c0 Mon Sep 17 00:00:00 2001 From: ascandone Date: Tue, 8 Jul 2025 21:43:29 +0200 Subject: [PATCH 27/59] test cmd flags --- internal/cmd/root.go | 2 +- internal/cmd/run.go | 12 +++++------ internal/cmd/test.go | 50 ++++++++++++++++++++++++++++++-------------- 3 files changed, 41 insertions(+), 23 deletions(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 35f993cd..557ad454 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -25,7 +25,7 @@ func Execute(options CliOptions) { rootCmd.AddCommand(lspCmd) rootCmd.AddCommand(checkCmd) - rootCmd.AddCommand(testCmd) + rootCmd.AddCommand(getTestCmd()) rootCmd.AddCommand(getRunCmd()) if err := rootCmd.Execute(); err != nil { diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 13341b0a..c90ed66d 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -20,7 +20,7 @@ const ( OutputFormatJson = "json" ) -type Args struct { +type runArgs struct { VariablesOpt string BalancesOpt string MetaOpt string @@ -37,7 +37,7 @@ type inputOpts struct { Balances interpreter.Balances `json:"balances"` } -func (o *inputOpts) fromRaw(opts Args) error { +func (o *inputOpts) fromRaw(opts runArgs) error { if opts.RawOpt == "" { return nil } @@ -49,7 +49,7 @@ func (o *inputOpts) fromRaw(opts Args) error { return nil } -func (o *inputOpts) fromStdin(opts Args) error { +func (o *inputOpts) fromStdin(opts runArgs) error { if !opts.StdinFlag { return nil } @@ -66,7 +66,7 @@ func (o *inputOpts) fromStdin(opts Args) error { return nil } -func (o *inputOpts) fromOptions(path string, opts Args) error { +func (o *inputOpts) fromOptions(path string, opts runArgs) error { if path != "" { numscriptContent, err := os.ReadFile(path) if err != nil { @@ -107,7 +107,7 @@ func (o *inputOpts) fromOptions(path string, opts Args) error { return nil } -func run(path string, opts Args) error { +func run(path string, opts runArgs) error { opt := inputOpts{ Variables: make(map[string]string), Meta: make(interpreter.AccountsMetadata), @@ -181,7 +181,7 @@ func showPretty(result *interpreter.ExecutionResult) { } func getRunCmd() *cobra.Command { - opts := Args{} + opts := runArgs{} cmd := cobra.Command{ Use: "run", diff --git a/internal/cmd/test.go b/internal/cmd/test.go index ddaec18c..61a58ab2 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -48,7 +48,7 @@ func showDiff(expected_ any, got_ any) { } func fixSnapshot(testResult testResult, failedAssertion specs_format.AssertionMismatch[any]) bool { - if !interactiveMode { + if !opts.interactive { return false } @@ -236,12 +236,12 @@ type testResult struct { Result specs_format.TestCaseResult } -func testPaths(paths []string) { +func testPaths() { testFiles := 0 failedTestFiles := 0 var allTests []testResult - for _, path := range paths { + for _, path := range opts.paths { path = strings.TrimSuffix(path, "/") glob := fmt.Sprintf(path + "/*.num.specs.json") @@ -277,7 +277,7 @@ func testPaths(paths []string) { rerun := showFailingTestCase(test_) if rerun { fmt.Print("\033[H\033[2J") - testPaths(paths) + testPaths() return } } @@ -287,8 +287,6 @@ func testPaths(paths []string) { } -var interactiveMode = false - func printFilesStats(allTests []testResult) { failedTests := utils.Filter(allTests, func(t testResult) bool { return !t.Result.Pass @@ -363,15 +361,35 @@ func printFilesStats(allTests []testResult) { } -var testCmd = &cobra.Command{ - Use: "test ", - Short: "Test a numscript file, using the corresponding spec file", - Args: cobra.MatchAll(), - Run: func(cmd *cobra.Command, paths []string) { - if len(paths) == 0 { - paths = []string{"."} - } +type testArgs struct { + paths []string + interactive bool +} + +var opts = testArgs{} + +func getTestCmd() *cobra.Command { + + cmd := &cobra.Command{ + Use: "test ", + Short: "Test a numscript file, using the corresponding spec file", + Args: cobra.MatchAll(), + Run: func(cmd *cobra.Command, paths []string) { + + if len(paths) == 0 { + paths = []string{"."} + } + + opts.paths = paths + testPaths() + }, + } + + // A poor man's feature flag + // that's a post-mvp feature so we'll keep it as dead code for now + if false { + cmd.Flags().BoolVar(&opts.interactive, "experimental-interactive", false, "Interactively update the expectations with the received value") + } - testPaths(paths) - }, + return cmd } From a62079773c4d011c72beda941b662668c040bc22 Mon Sep 17 00:00:00 2001 From: ascandone Date: Tue, 8 Jul 2025 21:45:55 +0200 Subject: [PATCH 28/59] removed comments --- internal/specs_format/index.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/internal/specs_format/index.go b/internal/specs_format/index.go index a6178d47..6fd66e32 100644 --- a/internal/specs_format/index.go +++ b/internal/specs_format/index.go @@ -66,7 +66,6 @@ func Check(program parser.Program, specs Specs) (SpecsResult, interpreter.Interp specsResult := SpecsResult{} for _, testCase := range specs.TestCases { - // TODO merge balances, vars, meta meta := mergeAccountsMeta(specs.Meta, testCase.Meta) balances := mergeBalances(specs.Balances, testCase.Balances) vars := mergeVars(specs.Vars, testCase.Vars) @@ -91,7 +90,6 @@ func Check(program parser.Program, specs Specs) (SpecsResult, interpreter.Interp var failedAssertions []AssertionMismatch[any] - // TODO recover err on missing funds if err != nil { _, ok := err.(interpreter.MissingFundsErr) if !ok { From 9dec055ea9ea948ee73a72efb71cf51d2a480ac0 Mon Sep 17 00:00:00 2001 From: ascandone Date: Tue, 8 Jul 2025 22:07:55 +0200 Subject: [PATCH 29/59] refactor --- internal/interpreter/accounts_metadata.go | 6 +++--- internal/interpreter/balances.go | 17 ++++------------- internal/interpreter/function_statements.go | 7 +++++-- internal/interpreter/interpreter.go | 5 ++++- internal/interpreter/utils.go | 11 ----------- internal/utils/utils.go | 18 ++++++++++++++++++ 6 files changed, 34 insertions(+), 30 deletions(-) delete mode 100644 internal/interpreter/utils.go diff --git a/internal/interpreter/accounts_metadata.go b/internal/interpreter/accounts_metadata.go index 81e206d9..a0cec91a 100644 --- a/internal/interpreter/accounts_metadata.go +++ b/internal/interpreter/accounts_metadata.go @@ -5,7 +5,7 @@ import ( ) func (m AccountsMetadata) fetchAccountMetadata(account string) AccountMetadata { - return defaultMapGet(m, account, func() AccountMetadata { + return utils.MapGetOrPutDefault(m, account, func() AccountMetadata { return AccountMetadata{} }) } @@ -15,7 +15,7 @@ func (m AccountsMetadata) DeepClone() AccountsMetadata { for account, accountBalances := range m { for asset, metadataValue := range accountBalances { clonedAccountBalances := cloned.fetchAccountMetadata(account) - defaultMapGet(clonedAccountBalances, asset, func() string { + utils.MapGetOrPutDefault(clonedAccountBalances, asset, func() string { return metadataValue }) } @@ -25,7 +25,7 @@ func (m AccountsMetadata) DeepClone() AccountsMetadata { func (m AccountsMetadata) Merge(update AccountsMetadata) { for acc, accBalances := range update { - cachedAcc := defaultMapGet(m, acc, func() AccountMetadata { + cachedAcc := utils.MapGetOrPutDefault(m, acc, func() AccountMetadata { return AccountMetadata{} }) diff --git a/internal/interpreter/balances.go b/internal/interpreter/balances.go index fa38cada..072bac44 100644 --- a/internal/interpreter/balances.go +++ b/internal/interpreter/balances.go @@ -7,18 +7,11 @@ import ( "github.com/formancehq/numscript/internal/utils" ) -func (b Balances) fetchAccountBalances(account string) AccountBalance { - return defaultMapGet(b, account, func() AccountBalance { - return AccountBalance{} - }) -} - func (b Balances) DeepClone() Balances { cloned := make(Balances) for account, accountBalances := range b { for asset, amount := range accountBalances { - clonedAccountBalances := cloned.fetchAccountBalances(account) - defaultMapGet(clonedAccountBalances, asset, func() *big.Int { + utils.NestedMapGetOrPutDefault(cloned, account, asset, func() *big.Int { return new(big.Int).Set(amount) }) } @@ -44,15 +37,13 @@ func coloredAsset(asset string, color *string) string { // Get the (account, asset) tuple from the Balances // if the tuple is not present, it will write a big.NewInt(0) in it and return it func (b Balances) fetchBalance(account string, uncoloredAsset string, color string) *big.Int { - accountBalances := b.fetchAccountBalances(account) - - return defaultMapGet(accountBalances, coloredAsset(uncoloredAsset, &color), func() *big.Int { + return utils.NestedMapGetOrPutDefault(b, account, coloredAsset(uncoloredAsset, &color), func() *big.Int { return new(big.Int) }) } func (b Balances) has(account string, asset string) bool { - accountBalances := defaultMapGet(b, account, func() AccountBalance { + accountBalances := utils.MapGetOrPutDefault(b, account, func() AccountBalance { return AccountBalance{} }) @@ -81,7 +72,7 @@ func (b Balances) filterQuery(q BalanceQuery) BalanceQuery { func (b Balances) Merge(update Balances) { // merge queried balance for acc, accBalances := range update { - cachedAcc := defaultMapGet(b, acc, func() AccountBalance { + cachedAcc := utils.MapGetOrPutDefault(b, acc, func() AccountBalance { return AccountBalance{} }) diff --git a/internal/interpreter/function_statements.go b/internal/interpreter/function_statements.go index edb77dba..89179b78 100644 --- a/internal/interpreter/function_statements.go +++ b/internal/interpreter/function_statements.go @@ -1,6 +1,9 @@ package interpreter -import "github.com/formancehq/numscript/internal/parser" +import ( + "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/utils" +) func setTxMeta(st *programState, r parser.Range, args []Value) InterpreterError { p := NewArgsParser(args) @@ -25,7 +28,7 @@ func setAccountMeta(st *programState, r parser.Range, args []Value) InterpreterE return err } - accountMeta := defaultMapGet(st.SetAccountsMeta, *account, func() AccountMetadata { + accountMeta := utils.MapGetOrPutDefault(st.SetAccountsMeta, *account, func() AccountMetadata { return AccountMetadata{} }) diff --git a/internal/interpreter/interpreter.go b/internal/interpreter/interpreter.go index e484bf22..570da1f4 100644 --- a/internal/interpreter/interpreter.go +++ b/internal/interpreter/interpreter.go @@ -46,7 +46,10 @@ func (s StaticStore) GetBalances(_ context.Context, q BalanceQuery) (Balances, e outputAccountBalance := AccountBalance{} outputBalance[queriedAccount] = outputAccountBalance - accountBalanceLookup := s.Balances.fetchAccountBalances(queriedAccount) + accountBalanceLookup := utils.MapGetOrPutDefault(s.Balances, queriedAccount, func() AccountBalance { + return AccountBalance{} + }) + for _, curr := range queriedCurrencies { n := new(big.Int) outputAccountBalance[curr] = n diff --git a/internal/interpreter/utils.go b/internal/interpreter/utils.go deleted file mode 100644 index a32bf667..00000000 --- a/internal/interpreter/utils.go +++ /dev/null @@ -1,11 +0,0 @@ -package interpreter - -func defaultMapGet[T any](m map[string]T, key string, getDefault func() T) T { - lookup, ok := m[key] - if !ok { - default_ := getDefault() - m[key] = default_ - return default_ - } - return lookup -} diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 53cab30c..d809575e 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -65,3 +65,21 @@ func Map[T any, U any](slice []T, f func(x T) U) []U { } return ret } + +func MapGetOrPutDefault[T any](m map[string]T, key string, getDefault func() T) T { + lookup, ok := m[key] + if !ok { + default_ := getDefault() + m[key] = default_ + return default_ + } + return lookup +} + +func NestedMapGetOrPutDefault[T any](m map[string]map[string]T, key1 string, key2 string, getDefault func() T) T { + m1 := MapGetOrPutDefault(m, key1, func() map[string]T { + return map[string]T{} + }) + + return MapGetOrPutDefault(m1, key2, getDefault) +} From a8ba47be545d1f2fb4ebfd7226900354a8d2ae8f Mon Sep 17 00:00:00 2001 From: ascandone Date: Tue, 8 Jul 2025 22:42:44 +0200 Subject: [PATCH 30/59] WIP broken --- internal/specs_format/index.go | 65 +++++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/internal/specs_format/index.go b/internal/specs_format/index.go index 6fd66e32..2a9fcf66 100644 --- a/internal/specs_format/index.go +++ b/internal/specs_format/index.go @@ -2,10 +2,13 @@ package specs_format import ( "context" + "fmt" + "math/big" "reflect" "github.com/formancehq/numscript/internal/interpreter" "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/utils" ) // --- Specs: @@ -24,10 +27,12 @@ type TestCase struct { Meta interpreter.AccountsMetadata `json:"accountsMeta,omitempty"` // Expectations + ExpectMissingFunds bool `json:"expect.missingFunds,omitempty"` ExpectedPostings []interpreter.Posting `json:"expect.postings"` ExpectedTxMeta map[string]string `json:"expect.txMeta,omitempty"` ExpectedAccountsMeta interpreter.AccountsMetadata `json:"expect.accountsMeta,omitempty"` - ExpectMissingFunds bool `json:"expect.missingFunds,omitempty"` + ExpectedVolumes interpreter.Balances `json:"expect.volumes,omitempty"` + ExpectedMovements Movements `json:"expect.movements,omitempty"` } type TestCaseResult struct { @@ -52,6 +57,10 @@ type SpecsResult struct { func runAssertion(failedAssertions []AssertionMismatch[any], assertion string, expected any, got any) []AssertionMismatch[any] { eq := reflect.DeepEqual(expected, got) if !eq { + fmt.Printf("%#v\n", expected) + fmt.Printf("%#v\n", got) + fmt.Printf("exp type: %T\n", expected) + fmt.Printf("got type: %T\n", got) return append(failedAssertions, AssertionMismatch[any]{ Assertion: assertion, Expected: expected, @@ -142,6 +151,22 @@ func Check(program parser.Program, specs Specs) (SpecsResult, interpreter.Interp ) } + if testCase.ExpectedVolumes != nil { + failedAssertions = runAssertion(failedAssertions, + "expect.volumes", + testCase.ExpectedVolumes, + getVolumes(result.Postings, balances), + ) + } + + if testCase.ExpectedMovements != nil { + failedAssertions = runAssertion(failedAssertions, + "expect.movements", + testCase.ExpectedMovements, + getMovements(result.Postings), + ) + } + } pass := len(failedAssertions) == 0 @@ -192,3 +217,41 @@ type AssertionMismatch[T any] struct { Expected T `json:"expected,omitempty"` Got T `json:"got,omitempty"` } + +// TODO test +type Movements = map[string]map[string]map[string]*big.Int + +func getMovements(postings []interpreter.Posting) Movements { + m := Movements{} + + for _, posting := range postings { + assetsMap := utils.NestedMapGetOrPutDefault(m, posting.Source, posting.Destination, func() map[string]*big.Int { + return map[string]*big.Int{} + }) + + amt := utils.MapGetOrPutDefault(assetsMap, posting.Asset, func() *big.Int { + return new(big.Int) + }) + + amt.Add(amt, posting.Amount) + } + + return m +} + +func getVolumes(postings []interpreter.Posting, initialBalances interpreter.Balances) interpreter.Balances { + balances := initialBalances.DeepClone() + for _, posting := range postings { + sourceBalance := utils.NestedMapGetOrPutDefault(balances, posting.Source, posting.Asset, func() *big.Int { + return new(big.Int) + }) + sourceBalance.Sub(sourceBalance, posting.Amount) + + destinationBalance := utils.NestedMapGetOrPutDefault(balances, posting.Destination, posting.Asset, func() *big.Int { + return new(big.Int) + }) + destinationBalance.Add(destinationBalance, posting.Amount) + } + + return balances +} From c6d8d56e8fbab6228c29ee5b8ac85bf9bfdd254c Mon Sep 17 00:00:00 2001 From: ascandone Date: Tue, 8 Jul 2025 23:07:09 +0200 Subject: [PATCH 31/59] fix assertions --- internal/interpreter/balances.go | 6 ++++++ internal/interpreter/balances_test.go | 17 +++++++++++++++++ internal/specs_format/index.go | 22 +++++++++++----------- internal/utils/utils.go | 21 +++++++++++++++++++++ 4 files changed, 55 insertions(+), 11 deletions(-) diff --git a/internal/interpreter/balances.go b/internal/interpreter/balances.go index 072bac44..222eb865 100644 --- a/internal/interpreter/balances.go +++ b/internal/interpreter/balances.go @@ -94,3 +94,9 @@ func (b Balances) PrettyPrint() string { } return utils.CsvPretty(header, rows, true) } + +func CompareBalances(b1 Balances, b2 Balances) bool { + return utils.Map2Cmp(b1, b2, func(ab1, ab2 *big.Int) bool { + return ab1.Cmp(ab2) == 0 + }) +} diff --git a/internal/interpreter/balances_test.go b/internal/interpreter/balances_test.go index 9690b00b..f41e24c9 100644 --- a/internal/interpreter/balances_test.go +++ b/internal/interpreter/balances_test.go @@ -62,3 +62,20 @@ func TestPrettyPrintBalance(t *testing.T) { snaps.MatchSnapshot(t, fullBalance.PrettyPrint()) } + +func TestCmpMaps(t *testing.T) { + + b1 := Balances{ + "alice": AccountBalance{ + "EUR": big.NewInt(100), + }, + } + + b2 := Balances{ + "alice": AccountBalance{ + "EUR": big.NewInt(42), + }, + } + + require.Equal(t, false, CompareBalances(b1, b2)) +} diff --git a/internal/specs_format/index.go b/internal/specs_format/index.go index 2a9fcf66..1259a010 100644 --- a/internal/specs_format/index.go +++ b/internal/specs_format/index.go @@ -2,7 +2,6 @@ package specs_format import ( "context" - "fmt" "math/big" "reflect" @@ -54,13 +53,9 @@ type SpecsResult struct { Cases []TestCaseResult } -func runAssertion(failedAssertions []AssertionMismatch[any], assertion string, expected any, got any) []AssertionMismatch[any] { - eq := reflect.DeepEqual(expected, got) +func runAssertion[T any](failedAssertions []AssertionMismatch[any], assertion string, expected T, got T, cmp func(T, T) bool) []AssertionMismatch[any] { + eq := cmp(expected, got) if !eq { - fmt.Printf("%#v\n", expected) - fmt.Printf("%#v\n", got) - fmt.Printf("exp type: %T\n", expected) - fmt.Printf("got type: %T\n", got) return append(failedAssertions, AssertionMismatch[any]{ Assertion: assertion, Expected: expected, @@ -124,10 +119,11 @@ func Check(program parser.Program, specs Specs) (SpecsResult, interpreter.Interp } if testCase.ExpectedPostings != nil { - failedAssertions = runAssertion(failedAssertions, + failedAssertions = runAssertion[any](failedAssertions, "expect.postings", testCase.ExpectedPostings, result.Postings, + reflect.DeepEqual, ) } @@ -136,18 +132,20 @@ func Check(program parser.Program, specs Specs) (SpecsResult, interpreter.Interp for k, v := range result.Metadata { metadata[k] = v.String() } - failedAssertions = runAssertion(failedAssertions, + failedAssertions = runAssertion[any](failedAssertions, "expect.txMeta", testCase.ExpectedTxMeta, metadata, + reflect.DeepEqual, ) } if testCase.ExpectedAccountsMeta != nil { - failedAssertions = runAssertion(failedAssertions, + failedAssertions = runAssertion[any](failedAssertions, "expect.accountsMeta", testCase.ExpectedAccountsMeta, result.AccountsMetadata, + reflect.DeepEqual, ) } @@ -156,14 +154,16 @@ func Check(program parser.Program, specs Specs) (SpecsResult, interpreter.Interp "expect.volumes", testCase.ExpectedVolumes, getVolumes(result.Postings, balances), + interpreter.CompareBalances, ) } if testCase.ExpectedMovements != nil { - failedAssertions = runAssertion(failedAssertions, + failedAssertions = runAssertion[any](failedAssertions, "expect.movements", testCase.ExpectedMovements, getMovements(result.Postings), + reflect.DeepEqual, ) } diff --git a/internal/utils/utils.go b/internal/utils/utils.go index d809575e..40e96ca7 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -83,3 +83,24 @@ func NestedMapGetOrPutDefault[T any](m map[string]map[string]T, key1 string, key return MapGetOrPutDefault(m1, key2, getDefault) } + +func MapCmp[T any](m1, m2 map[string]T, cmp func(x1 T, x2 T) bool) bool { + if len(m1) != len(m2) { + return false + } + + for k1, v1 := range m1 { + v2, ok := m2[k1] + if !ok || !cmp(v1, v2) { + return false + } + } + + return true +} + +func Map2Cmp[T any](m1, m2 map[string]map[string]T, cmp func(x1 T, x2 T) bool) bool { + return MapCmp(m1, m2, func(nested1, nested2 map[string]T) bool { + return MapCmp(nested1, nested2, cmp) + }) +} From a3dfec2a5902f5d1959d01cc4467b234e16fd722 Mon Sep 17 00:00:00 2001 From: ascandone Date: Thu, 10 Jul 2025 13:05:11 +0200 Subject: [PATCH 32/59] fix newline --- internal/cmd/test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/test.go b/internal/cmd/test.go index 61a58ab2..d0cea180 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -352,7 +352,7 @@ func printFilesStats(allTests []testResult) { testsUI := strings.Join(testUIParts, ansi.ColorBrightBlack(" | ")) totalTestsUI := ansi.ColorBrightBlack(fmt.Sprintf("(%d)", testsCount)) - fmt.Print(paddedLabel(testsLabel) + " " + testsUI + " " + totalTestsUI) + fmt.Println(paddedLabel(testsLabel) + " " + testsUI + " " + totalTestsUI) if failedTestsCount != 0 { os.Exit(1) From dc28320b37e7f4dfc45070b53317793ff6beb7e1 Mon Sep 17 00:00:00 2001 From: ascandone Date: Thu, 10 Jul 2025 17:18:28 +0200 Subject: [PATCH 33/59] defined schema for specs format --- specs.schema.json | 155 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 specs.schema.json diff --git a/specs.schema.json b/specs.schema.json new file mode 100644 index 00000000..0bc45119 --- /dev/null +++ b/specs.schema.json @@ -0,0 +1,155 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Specs", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { "type": "string" }, + "balances": { + "$ref": "#/definitions/Balances" + }, + "vars": { + "$ref": "#/definitions/VariablesMap" + }, + "accountsMeta": { + "$ref": "#/definitions/AccountsMetadata" + }, + "testCases": { + "type": "array", + "items": { "$ref": "#/definitions/TestCase" } + }, + "featureFlags": { + "type": "array", + "items": { "type": "string" } + } + }, + "definitions": { + "TestCase": { + "type": "object", + "properties": { + "it": { + "type": "string", + "description": "Test case description" + }, + "balances": { + "$ref": "#/definitions/Balances" + }, + "vars": { + "$ref": "#/definitions/VariablesMap" + }, + "accountsMeta": { + "$ref": "#/definitions/AccountsMetadata" + }, + "expect.postings": { + "type": "array", + "items": { "$ref": "#/definitions/Posting" } + }, + + "expect.volumes": { + "$ref": "#/definitions/Balances" + }, + + "expect.movements": { + "$ref": "#/definitions/Movements" + }, + + "expect.txMeta": { + "$ref": "#/definitions/TxMetadata" + }, + "expect.missingFunds": { + "type": "boolean" + } + }, + "required": ["it"] + }, + + "Bigint": { + "oneOf": [ + { "type": "string", "pattern": "^-?\\d+$" }, + { "type": "number" } + ] + }, + + "Balances": { + "type": "object", + "description": "Map of account names to asset balances", + "additionalProperties": false, + "patternProperties": { + "^([a-zA-Z0-9_-]+(:[a-zA-Z0-9_-]+)*)$": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^([A-Z]+(/[0-9]+)?)$": { + "$ref": "#/definitions/Bigint" + } + } + } + } + }, + + "VariablesMap": { + "type": "object", + "description": "Map of variable name to variable stringified value", + "additionalProperties": false, + "patternProperties": { + "^[a-z_]+$": { "type": "string" } + } + }, + + "AccountsMetadata": { + "type": "object", + "description": "Map of an account metadata to the account's metadata", + "additionalProperties": false, + "patternProperties": { + "^([a-zA-Z0-9_-]+(:[a-zA-Z0-9_-]+)*)$": { + "type": "object", + "additionalProperties": { "type": "string" } + } + } + }, + + "TxMetadata": { + "type": "object", + "description": "Map from a metadata's key to the transaction's metadata stringied value", + "additionalProperties": { "type": "string" } + }, + + "Movements": { + "type": "object", + "description": "The funds sent from an account to another", + "additionalProperties": false, + "patternProperties": { + "^([a-zA-Z0-9_-]+(:[a-zA-Z0-9_-]+)*)$": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^([a-zA-Z0-9_-]+(:[a-zA-Z0-9_-]+)*)$": { + "type": "object", + "patternProperties": { + "^([A-Z]+(/[0-9]+)?)$": { + "$ref": "#/definitions/Bigint" + } + } + } + } + } + } + }, + + "Posting": { + "type": "object", + "properties": { + "source": { "type": "string" }, + "destination": { "type": "string" }, + "asset": { + "type": "string", + "pattern": "^([A-Z]+(/[0-9]+)?)$" + }, + "amount": { + "$ref": "#/definitions/Bigint" + } + }, + "required": ["source", "destination", "asset", "amount"] + } + } +} From 420fd5cae650bc0f4d0678c6720ae1c33616b154 Mon Sep 17 00:00:00 2001 From: ascandone Date: Thu, 10 Jul 2025 18:00:57 +0200 Subject: [PATCH 34/59] improve description --- internal/cmd/test.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/cmd/test.go b/internal/cmd/test.go index d0cea180..42352294 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -371,9 +371,12 @@ var opts = testArgs{} func getTestCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "test ", - Short: "Test a numscript file, using the corresponding spec file", - Args: cobra.MatchAll(), + Use: "test folder...", + Short: "Test numscript file using the numscript specs format", + Long: `Searches for any .num.specs files in the given directory (or directories), +and tests the corresponding .num file (if any). +Defaults to "." if there are no given paths`, + Args: cobra.MatchAll(), Run: func(cmd *cobra.Command, paths []string) { if len(paths) == 0 { From 33ee1248e83575604bbda360d3418eb256e24acd Mon Sep 17 00:00:00 2001 From: ascandone Date: Thu, 10 Jul 2025 18:02:11 +0200 Subject: [PATCH 35/59] change fields name --- internal/cmd/test.go | 2 +- internal/specs_format/index.go | 32 ++++++++++++++--------------- internal/specs_format/parse_test.go | 2 +- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/internal/cmd/test.go b/internal/cmd/test.go index 42352294..09f0c9d2 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -71,7 +71,7 @@ func fixSnapshot(testResult testResult, failedAssertion specs_format.AssertionMi if t.It == testResult.Result.It { switch failedAssertion.Expected { case "expect.postings": - t.ExpectedPostings = failedAssertion.Expected.([]interpreter.Posting) + t.ExpectPostings = failedAssertion.Expected.([]interpreter.Posting) default: panic("TODO implement") diff --git a/internal/specs_format/index.go b/internal/specs_format/index.go index 1259a010..e267d6c4 100644 --- a/internal/specs_format/index.go +++ b/internal/specs_format/index.go @@ -26,12 +26,12 @@ type TestCase struct { Meta interpreter.AccountsMetadata `json:"accountsMeta,omitempty"` // Expectations - ExpectMissingFunds bool `json:"expect.missingFunds,omitempty"` - ExpectedPostings []interpreter.Posting `json:"expect.postings"` - ExpectedTxMeta map[string]string `json:"expect.txMeta,omitempty"` - ExpectedAccountsMeta interpreter.AccountsMetadata `json:"expect.accountsMeta,omitempty"` - ExpectedVolumes interpreter.Balances `json:"expect.volumes,omitempty"` - ExpectedMovements Movements `json:"expect.movements,omitempty"` + ExpectMissingFunds bool `json:"expect.missingFunds,omitempty"` + ExpectPostings []interpreter.Posting `json:"expect.postings"` + ExpectTxMeta map[string]string `json:"expect.txMeta,omitempty"` + ExpectAccountsMeta interpreter.AccountsMetadata `json:"expect.accountsMeta,omitempty"` + ExpectVolumes interpreter.Balances `json:"expect.volumes,omitempty"` + ExpectMovements Movements `json:"expect.movements,omitempty"` } type TestCaseResult struct { @@ -118,50 +118,50 @@ func Check(program parser.Program, specs Specs) (SpecsResult, interpreter.Interp }) } - if testCase.ExpectedPostings != nil { + if testCase.ExpectPostings != nil { failedAssertions = runAssertion[any](failedAssertions, "expect.postings", - testCase.ExpectedPostings, + testCase.ExpectPostings, result.Postings, reflect.DeepEqual, ) } - if testCase.ExpectedTxMeta != nil { + if testCase.ExpectTxMeta != nil { metadata := map[string]string{} for k, v := range result.Metadata { metadata[k] = v.String() } failedAssertions = runAssertion[any](failedAssertions, "expect.txMeta", - testCase.ExpectedTxMeta, + testCase.ExpectTxMeta, metadata, reflect.DeepEqual, ) } - if testCase.ExpectedAccountsMeta != nil { + if testCase.ExpectAccountsMeta != nil { failedAssertions = runAssertion[any](failedAssertions, "expect.accountsMeta", - testCase.ExpectedAccountsMeta, + testCase.ExpectAccountsMeta, result.AccountsMetadata, reflect.DeepEqual, ) } - if testCase.ExpectedVolumes != nil { + if testCase.ExpectVolumes != nil { failedAssertions = runAssertion(failedAssertions, "expect.volumes", - testCase.ExpectedVolumes, + testCase.ExpectVolumes, getVolumes(result.Postings, balances), interpreter.CompareBalances, ) } - if testCase.ExpectedMovements != nil { + if testCase.ExpectMovements != nil { failedAssertions = runAssertion[any](failedAssertions, "expect.movements", - testCase.ExpectedMovements, + testCase.ExpectMovements, getMovements(result.Postings), reflect.DeepEqual, ) diff --git a/internal/specs_format/parse_test.go b/internal/specs_format/parse_test.go index 3caaddb9..e5b990bd 100644 --- a/internal/specs_format/parse_test.go +++ b/internal/specs_format/parse_test.go @@ -61,7 +61,7 @@ func TestParseSpecs(t *testing.T) { "EUR": big.NewInt(42), }, }, - ExpectedPostings: []interpreter.Posting{ + ExpectPostings: []interpreter.Posting{ { Source: "src", Destination: "dest", From 63943692ac018dcce5bf726d408a5ef13acf57f3 Mon Sep 17 00:00:00 2001 From: ascandone Date: Thu, 10 Jul 2025 18:16:15 +0200 Subject: [PATCH 36/59] change color --- internal/cmd/test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/test.go b/internal/cmd/test.go index 09f0c9d2..9436be1b 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -185,7 +185,7 @@ func test(specsFilePath string) (specs_format.Specs, specs_format.SpecsResult) { var specs specs_format.Specs err = json.Unmarshal([]byte(specsFileContent), &specs) if err != nil { - os.Stderr.Write([]byte(err.Error())) + os.Stderr.Write([]byte(ansi.ColorRed(err.Error()))) os.Exit(1) } From 779d6c353eb4804f5d158e43ecc1746cbd6d927d Mon Sep 17 00:00:00 2001 From: ascandone Date: Thu, 10 Jul 2025 19:27:02 +0200 Subject: [PATCH 37/59] fix schema --- specs.schema.json | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/specs.schema.json b/specs.schema.json index 0bc45119..457f7d3c 100644 --- a/specs.schema.json +++ b/specs.schema.json @@ -63,13 +63,6 @@ "required": ["it"] }, - "Bigint": { - "oneOf": [ - { "type": "string", "pattern": "^-?\\d+$" }, - { "type": "number" } - ] - }, - "Balances": { "type": "object", "description": "Map of account names to asset balances", @@ -80,7 +73,7 @@ "additionalProperties": false, "patternProperties": { "^([A-Z]+(/[0-9]+)?)$": { - "$ref": "#/definitions/Bigint" + "type": "number" } } } @@ -127,7 +120,7 @@ "type": "object", "patternProperties": { "^([A-Z]+(/[0-9]+)?)$": { - "$ref": "#/definitions/Bigint" + "type": "number" } } } @@ -146,7 +139,7 @@ "pattern": "^([A-Z]+(/[0-9]+)?)$" }, "amount": { - "$ref": "#/definitions/Bigint" + "type": "number" } }, "required": ["source", "destination", "asset", "amount"] From 22622512c312e190bbd3fc26b318b4312c7b7122 Mon Sep 17 00:00:00 2001 From: ascandone Date: Sat, 12 Jul 2025 17:50:05 +0200 Subject: [PATCH 38/59] add required field to schema --- specs.schema.json | 1 + 1 file changed, 1 insertion(+) diff --git a/specs.schema.json b/specs.schema.json index 457f7d3c..ff66157e 100644 --- a/specs.schema.json +++ b/specs.schema.json @@ -3,6 +3,7 @@ "title": "Specs", "type": "object", "additionalProperties": false, + "required": ["testCases"], "properties": { "$schema": { "type": "string" }, "balances": { From d695d094d74cbdf808d4b056cb54498497d93373 Mon Sep 17 00:00:00 2001 From: ascandone Date: Sat, 12 Jul 2025 17:57:34 +0200 Subject: [PATCH 39/59] update schema --- specs.schema.json | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/specs.schema.json b/specs.schema.json index ff66157e..2b65a9a0 100644 --- a/specs.schema.json +++ b/specs.schema.json @@ -27,6 +27,8 @@ "definitions": { "TestCase": { "type": "object", + "required": ["it"], + "additionalProperties": false, "properties": { "it": { "type": "string", @@ -57,11 +59,15 @@ "expect.txMeta": { "$ref": "#/definitions/TxMetadata" }, + + "expect.accountsMeta": { + "$ref": "#/definitions/AccountsMetadata" + }, + "expect.missingFunds": { "type": "boolean" } - }, - "required": ["it"] + } }, "Balances": { From be21b89d3c443281940c5d0f349dbdc46402a443 Mon Sep 17 00:00:00 2001 From: ascandone Date: Mon, 14 Jul 2025 17:33:51 +0200 Subject: [PATCH 40/59] minor --- internal/specs_format/index.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/specs_format/index.go b/internal/specs_format/index.go index e267d6c4..0d57e1a3 100644 --- a/internal/specs_format/index.go +++ b/internal/specs_format/index.go @@ -27,7 +27,7 @@ type TestCase struct { // Expectations ExpectMissingFunds bool `json:"expect.missingFunds,omitempty"` - ExpectPostings []interpreter.Posting `json:"expect.postings"` + ExpectPostings []interpreter.Posting `json:"expect.postings,omitempty"` ExpectTxMeta map[string]string `json:"expect.txMeta,omitempty"` ExpectAccountsMeta interpreter.AccountsMetadata `json:"expect.accountsMeta,omitempty"` ExpectVolumes interpreter.Balances `json:"expect.volumes,omitempty"` From 1d9901ffec7f3a6d3e1a036e3d399abd4cd3e015 Mon Sep 17 00:00:00 2001 From: ascandone Date: Mon, 14 Jul 2025 17:57:55 +0200 Subject: [PATCH 41/59] polish errors --- internal/cmd/test.go | 42 ++++++++++++++++++++++++++------------- internal/parser/parser.go | 8 ++++++-- 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/internal/cmd/test.go b/internal/cmd/test.go index 9436be1b..493b4af0 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -159,6 +159,20 @@ func showFailingTestCase(testResult testResult) (rerun bool) { return false } +func showErr(filename string, script string, err interpreter.InterpreterError) { + rng := err.GetRange() + + errFile := fmt.Sprintf("\nError: %s:%d:%d\n\n", filename, rng.Start.Line+1, rng.Start.Character+1) + os.Stderr.Write([]byte(ansi.ColorRed(errFile))) + + os.Stderr.Write([]byte(err.Error() + "\n\n")) + + if rng.Start != rng.End { + os.Stderr.Write([]byte("\n")) + os.Stderr.Write([]byte(rng.ShowOnSource(script) + "\n")) + } +} + func test(specsFilePath string) (specs_format.Specs, specs_format.SpecsResult) { if !strings.HasSuffix(specsFilePath, ".num.specs.json") { panic("Wrong name") @@ -173,8 +187,12 @@ func test(specsFilePath string) (specs_format.Specs, specs_format.SpecsResult) { } parseResult := parser.Parse(string(numscriptContent)) - // TODO assert no parse err - // TODO we might want to do static checking + if len(parseResult.Errors) != 0 { + for _, err := range parseResult.Errors { + showErr(numscriptFileName, string(numscriptContent), err) + } + os.Exit(1) + } specsFileContent, err := os.ReadFile(specsFilePath) if err != nil { @@ -185,23 +203,14 @@ func test(specsFilePath string) (specs_format.Specs, specs_format.SpecsResult) { var specs specs_format.Specs err = json.Unmarshal([]byte(specsFileContent), &specs) if err != nil { - os.Stderr.Write([]byte(ansi.ColorRed(err.Error()))) + os.Stderr.Write([]byte(ansi.ColorRed(fmt.Sprintf("\nError: %s\n\n", specsFilePath)))) + os.Stderr.Write([]byte(err.Error() + "\n")) os.Exit(1) } out, iErr := specs_format.Check(parseResult.Value, specs) if iErr != nil { - rng := iErr.GetRange() - - errFile := fmt.Sprintf("\nError: %s:%d:%d\n\n", numscriptFileName, rng.Start.Line+1, rng.Start.Character+1) - - os.Stderr.Write([]byte(ansi.ColorRed(errFile))) - - os.Stderr.Write([]byte(iErr.Error())) - if rng.Start != rng.End { - os.Stderr.Write([]byte("\n")) - os.Stderr.Write([]byte(iErr.GetRange().ShowOnSource(parseResult.Source))) - } + showErr(numscriptFileName, string(numscriptContent), iErr) os.Exit(1) } @@ -252,6 +261,11 @@ func testPaths() { } testFiles += len(files) + if len(files) == 0 { + os.Stderr.Write([]byte(ansi.ColorRed("No specs files found\n"))) + os.Exit(1) + } + for _, file := range files { specs, out := test(file) diff --git a/internal/parser/parser.go b/internal/parser/parser.go index aec04672..0f9f9eb1 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -12,8 +12,12 @@ import ( ) type ParserError struct { - Range Range - Msg string + Range + Msg string +} + +func (e ParserError) Error() string { + return e.Msg } type ParseResult struct { From 8bc2ded623f829158b4fc5ad794408a74cbea9b2 Mon Sep 17 00:00:00 2001 From: ascandone Date: Wed, 16 Jul 2025 12:13:25 +0200 Subject: [PATCH 42/59] fix --- internal/cmd/run.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/cmd/run.go b/internal/cmd/run.go index c90ed66d..db8e5c47 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -170,7 +170,7 @@ func showJson(result *interpreter.ExecutionResult) error { return err } -func showPretty(result *interpreter.ExecutionResult) { +func showPretty(result *interpreter.ExecutionResult) error { fmt.Println("Postings:") fmt.Println(interpreter.PrettyPrintPostings(result.Postings)) @@ -178,6 +178,8 @@ func showPretty(result *interpreter.ExecutionResult) { fmt.Println("Meta:") fmt.Println(interpreter.PrettyPrintMeta(result.Metadata)) } + + return nil } func getRunCmd() *cobra.Command { From f099c1cfd196411d9e6ec6e9dbb9121144a4b326 Mon Sep 17 00:00:00 2001 From: ascandone Date: Wed, 16 Jul 2025 12:18:11 +0200 Subject: [PATCH 43/59] linter fix --- internal/cmd/test.go | 18 +++++++++--------- internal/parser/parser.go | 2 +- internal/utils/pretty_csv.go | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/internal/cmd/test.go b/internal/cmd/test.go index 493b4af0..5bfc5545 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -163,13 +163,13 @@ func showErr(filename string, script string, err interpreter.InterpreterError) { rng := err.GetRange() errFile := fmt.Sprintf("\nError: %s:%d:%d\n\n", filename, rng.Start.Line+1, rng.Start.Character+1) - os.Stderr.Write([]byte(ansi.ColorRed(errFile))) + _, _ = os.Stderr.Write([]byte(ansi.ColorRed(errFile))) - os.Stderr.Write([]byte(err.Error() + "\n\n")) + _, _ = os.Stderr.Write([]byte(err.Error() + "\n\n")) if rng.Start != rng.End { - os.Stderr.Write([]byte("\n")) - os.Stderr.Write([]byte(rng.ShowOnSource(script) + "\n")) + _, _ = os.Stderr.Write([]byte("\n")) + _, _ = os.Stderr.Write([]byte(rng.ShowOnSource(script) + "\n")) } } @@ -182,7 +182,7 @@ func test(specsFilePath string) (specs_format.Specs, specs_format.SpecsResult) { numscriptContent, err := os.ReadFile(numscriptFileName) if err != nil { - os.Stderr.Write([]byte(err.Error())) + _, _ = os.Stderr.Write([]byte(err.Error())) os.Exit(1) } @@ -196,15 +196,15 @@ func test(specsFilePath string) (specs_format.Specs, specs_format.SpecsResult) { specsFileContent, err := os.ReadFile(specsFilePath) if err != nil { - os.Stderr.Write([]byte(err.Error())) + _, _ = os.Stderr.Write([]byte(err.Error())) os.Exit(1) } var specs specs_format.Specs err = json.Unmarshal([]byte(specsFileContent), &specs) if err != nil { - os.Stderr.Write([]byte(ansi.ColorRed(fmt.Sprintf("\nError: %s\n\n", specsFilePath)))) - os.Stderr.Write([]byte(err.Error() + "\n")) + _, _ = os.Stderr.Write([]byte(ansi.ColorRed(fmt.Sprintf("\nError: %s\n\n", specsFilePath)))) + _, _ = os.Stderr.Write([]byte(err.Error() + "\n")) os.Exit(1) } @@ -262,7 +262,7 @@ func testPaths() { testFiles += len(files) if len(files) == 0 { - os.Stderr.Write([]byte(ansi.ColorRed("No specs files found\n"))) + _, _ = os.Stderr.Write([]byte(ansi.ColorRed("No specs files found\n"))) os.Exit(1) } diff --git a/internal/parser/parser.go b/internal/parser/parser.go index 0f9f9eb1..e997af82 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -83,7 +83,7 @@ func Parse(input string) ParseResult { func ParseErrorsToString(errors []ParserError, source string) string { buf := "Got errors while parsing:\n" for _, err := range errors { - buf += err.Msg + "\n" + err.Range.ShowOnSource(source) + "\n" + buf += err.Msg + "\n" + err.ShowOnSource(source) + "\n" } return buf } diff --git a/internal/utils/pretty_csv.go b/internal/utils/pretty_csv.go index 41710f03..aef8337e 100644 --- a/internal/utils/pretty_csv.go +++ b/internal/utils/pretty_csv.go @@ -29,7 +29,7 @@ func CsvPretty( } // -- Find paddings - var maxLengths []int = make([]int, len(header)) + var maxLengths = make([]int, len(header)) for fieldIndex, fieldName := range header { maxLen := len(fieldName) From a888a9d0979c327a6d05f805c54f648c7662bdd8 Mon Sep 17 00:00:00 2001 From: ascandone Date: Wed, 16 Jul 2025 12:41:43 +0200 Subject: [PATCH 44/59] improve coverage --- internal/cmd/__snapshots__/test_test.snap | 8 ++++++++ internal/cmd/test.go | 11 ++++++----- internal/cmd/test_test.go | 18 ++++++++++++++++++ 3 files changed, 32 insertions(+), 5 deletions(-) create mode 100755 internal/cmd/__snapshots__/test_test.snap create mode 100644 internal/cmd/test_test.go diff --git a/internal/cmd/__snapshots__/test_test.snap b/internal/cmd/__snapshots__/test_test.snap new file mode 100755 index 00000000..005e40d3 --- /dev/null +++ b/internal/cmd/__snapshots__/test_test.snap @@ -0,0 +1,8 @@ + +[TestShowDiff - 1] + { +- "x": 42 ++ "x": 100 + } + +--- diff --git a/internal/cmd/test.go b/internal/cmd/test.go index 5bfc5545..8f73d37c 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -4,6 +4,7 @@ import ( "bufio" "encoding/json" "fmt" + "io" "os" "path/filepath" "slices" @@ -19,7 +20,7 @@ import ( "github.com/sergi/go-diff/diffmatchpatch" ) -func showDiff(expected_ any, got_ any) { +func showDiff(w io.Writer, expected_ any, got_ any) { dmp := diffmatchpatch.New() expected, _ := json.MarshalIndent(expected_, "", " ") @@ -37,11 +38,11 @@ func showDiff(expected_ any, got_ any) { } switch diff.Type { case diffmatchpatch.DiffDelete: - fmt.Println(ansi.ColorGreen("- " + line)) + fmt.Fprintln(w, ansi.ColorGreen("- "+line)) case diffmatchpatch.DiffInsert: - fmt.Println(ansi.ColorRed("+ " + line)) + fmt.Fprintln(w, ansi.ColorRed("+ "+line)) case diffmatchpatch.DiffEqual: - fmt.Println(ansi.ColorBrightBlack(" " + line)) + fmt.Fprintln(w, ansi.ColorBrightBlack(" "+line)) } } } @@ -147,7 +148,7 @@ func showFailingTestCase(testResult testResult) (rerun bool) { fmt.Println(ansi.Underline(failedAssertion.Assertion)) fmt.Println() - showDiff(failedAssertion.Expected, failedAssertion.Got) + showDiff(os.Stdout, failedAssertion.Expected, failedAssertion.Got) rerun := fixSnapshot(testResult, failedAssertion) if rerun { diff --git a/internal/cmd/test_test.go b/internal/cmd/test_test.go new file mode 100644 index 00000000..6d64eef8 --- /dev/null +++ b/internal/cmd/test_test.go @@ -0,0 +1,18 @@ +package cmd + +import ( + "bytes" + "testing" + + "github.com/gkampitakis/go-snaps/snaps" +) + +func TestShowDiff(t *testing.T) { + var buf bytes.Buffer + showDiff( + &buf, + map[string]any{"x": 42}, + map[string]any{"x": 100}, + ) + snaps.MatchSnapshot(t, buf.String()) +} From d905a87b029ac226adfe8372e87297424b540541 Mon Sep 17 00:00:00 2001 From: ascandone Date: Wed, 16 Jul 2025 15:52:28 +0200 Subject: [PATCH 45/59] improved coverage --- internal/cmd/__snapshots__/test_test.snap | 28 ++ internal/cmd/test.go | 400 ++++++++++------------ internal/cmd/test_test.go | 60 +++- 3 files changed, 263 insertions(+), 225 deletions(-) diff --git a/internal/cmd/__snapshots__/test_test.snap b/internal/cmd/__snapshots__/test_test.snap index 005e40d3..c72b0ab7 100755 --- a/internal/cmd/__snapshots__/test_test.snap +++ b/internal/cmd/__snapshots__/test_test.snap @@ -1,8 +1,36 @@ [TestShowDiff - 1]  { + "common": "ok", - "x": 42 + "x": 100  } --- + +[TestSingleTest - 1] +❯ exmaple.num (2 tests | 1 failed) + × tfailing + + + FAIL  example.num.specs.json > tfailing + +- Expected ++ Received + +expect.postings + + [ + { +- "source": "wrong-source", ++ "source": "world", + "destination": "dest", + "amount": 100, + "asset": "USD/2" + } + ] + + Test files  1 failed (1) + Tests  1 failed | 1 passed (2) + +--- diff --git a/internal/cmd/test.go b/internal/cmd/test.go index 8f73d37c..a685e21b 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -1,7 +1,6 @@ package cmd import ( - "bufio" "encoding/json" "fmt" "io" @@ -20,289 +19,245 @@ import ( "github.com/sergi/go-diff/diffmatchpatch" ) -func showDiff(w io.Writer, expected_ any, got_ any) { - dmp := diffmatchpatch.New() - - expected, _ := json.MarshalIndent(expected_, "", " ") - actual, _ := json.MarshalIndent(got_, "", " ") - - aChars, bChars, lineArray := dmp.DiffLinesToChars(string(expected), string(actual)) - diffs := dmp.DiffMain(aChars, bChars, true) - diffs = dmp.DiffCharsToLines(diffs, lineArray) - - for _, diff := range diffs { - lines := strings.Split(diff.Text, "\n") - for _, line := range lines { - if line == "" { - continue - } - switch diff.Type { - case diffmatchpatch.DiffDelete: - fmt.Fprintln(w, ansi.ColorGreen("- "+line)) - case diffmatchpatch.DiffInsert: - fmt.Fprintln(w, ansi.ColorRed("+ "+line)) - case diffmatchpatch.DiffEqual: - fmt.Fprintln(w, ansi.ColorBrightBlack(" "+line)) - } - } - } +type rawSpec struct { + NumscriptPath string + SpecsPath string + NumscriptContent string + SpecsFileContent []byte } -func fixSnapshot(testResult testResult, failedAssertion specs_format.AssertionMismatch[any]) bool { - if !opts.interactive { - return false - } - - fmt.Println(ansi.ColorBrightBlack( - fmt.Sprintf("\nPress %s to update snapshot, %s to go the the next one", - ansi.ColorBrightYellow("u"), - ansi.ColorBrightYellow("n"), - ))) - - reader := bufio.NewReader(os.Stdin) - line, _, err := reader.ReadLine() - if err != nil { - panic(err) - } - - switch string(line) { - case "u": - testResult.Specs.TestCases = utils.Map(testResult.Specs.TestCases, func(t specs_format.TestCase) specs_format.TestCase { - // TODO check there are no duplicate "It" - if t.It == testResult.Result.It { - switch failedAssertion.Expected { - case "expect.postings": - t.ExpectPostings = failedAssertion.Expected.([]interpreter.Posting) - - default: - panic("TODO implement") - - } +func readSpecsFiles() []rawSpec { + var specs []rawSpec - } - - return t - }) + for _, path := range opts.paths { + path = strings.TrimSuffix(path, "/") - newSpecs, err := json.MarshalIndent(testResult.Specs, "", " ") + specsFilePaths, err := filepath.Glob(path + "/*.num.specs.json") if err != nil { panic(err) } - err = os.WriteFile(testResult.File, newSpecs, os.ModePerm) - if err != nil { - panic(err) + if len(specsFilePaths) == 0 { + _, _ = os.Stderr.Write([]byte(ansi.ColorRed("No specs files found\n"))) + os.Exit(1) } - return true - - case "n": - return false - default: - panic("TODO invalid command") - } -} - -func showFailingTestCase(testResult testResult) (rerun bool) { - specsFilePath := testResult.File - result := testResult.Result - - if result.Pass { - return false - } - - fmt.Print("\n\n") + for _, specsFilePath := range specsFilePaths { + numscriptFileName := strings.TrimSuffix(specsFilePath, ".specs.json") - failColor := ansi.Compose(ansi.BgRed, ansi.ColorLight, ansi.Bold) - fmt.Print(failColor(" FAIL ")) - fmt.Println(ansi.ColorRed(" " + specsFilePath + " > " + result.It)) + // TODO Improve err message ("no matching numscript for specsfile") + numscriptContent, err := os.ReadFile(numscriptFileName) + if err != nil { + _, _ = os.Stderr.Write([]byte(err.Error())) + os.Exit(1) + } - showGiven := len(result.Balances) != 0 || len(result.Meta) != 0 || len(result.Vars) != 0 - if showGiven { - fmt.Println(ansi.Underline("\nGIVEN:")) - } + specsFileContent, err := os.ReadFile(specsFilePath) + if err != nil { + _, _ = os.Stderr.Write([]byte(err.Error())) + os.Exit(1) + } - if len(result.Balances) != 0 { - fmt.Println() - fmt.Println(result.Balances.PrettyPrint()) - fmt.Println() + specs = append(specs, rawSpec{ + NumscriptPath: numscriptFileName, + SpecsPath: specsFilePath, + NumscriptContent: string(numscriptContent), + SpecsFileContent: specsFileContent, + }) + } } - if len(result.Meta) != 0 { - fmt.Println() - fmt.Println(result.Meta.PrettyPrint()) - fmt.Println() - } + return specs +} - if len(result.Vars) != 0 { - fmt.Println() - fmt.Println(utils.CsvPrettyMap("Name", "Value", result.Vars)) - fmt.Println() +func runRawSpecs(stdout io.Writer, stderr io.Writer, rawSpecs []rawSpec) bool { + if len(rawSpecs) == 0 { + _, _ = stderr.Write([]byte(ansi.ColorRed("No specs files found\n"))) + return false } - fmt.Println() - fmt.Println(ansi.ColorGreen("- Expected")) - fmt.Println(ansi.ColorRed("+ Received\n")) - - for _, failedAssertion := range result.FailedAssertions { + failedTestFiles := 0 - fmt.Println(ansi.Underline(failedAssertion.Assertion)) - fmt.Println() - showDiff(os.Stdout, failedAssertion.Expected, failedAssertion.Got) + var allTests []testResult - rerun := fixSnapshot(testResult, failedAssertion) - if rerun { - return true + for _, rawSpec := range rawSpecs { + specs, out, ok := runRawSpec(stdout, stderr, rawSpec) + if !ok { + return false } - } - - return false -} - -func showErr(filename string, script string, err interpreter.InterpreterError) { - rng := err.GetRange() - - errFile := fmt.Sprintf("\nError: %s:%d:%d\n\n", filename, rng.Start.Line+1, rng.Start.Character+1) - _, _ = os.Stderr.Write([]byte(ansi.ColorRed(errFile))) + // Count tests + isTestFailed := slices.ContainsFunc(out.Cases, func(tc specs_format.TestCaseResult) bool { + return tc.Pass + }) + if isTestFailed { + failedTestFiles += 1 + } - _, _ = os.Stderr.Write([]byte(err.Error() + "\n\n")) + for _, caseResult := range out.Cases { + allTests = append(allTests, testResult{ + Specs: specs, + Result: caseResult, + File: rawSpec.SpecsPath, + }) + } - if rng.Start != rng.End { - _, _ = os.Stderr.Write([]byte("\n")) - _, _ = os.Stderr.Write([]byte(rng.ShowOnSource(script) + "\n")) } -} -func test(specsFilePath string) (specs_format.Specs, specs_format.SpecsResult) { - if !strings.HasSuffix(specsFilePath, ".num.specs.json") { - panic("Wrong name") + for _, test_ := range allTests { + showFailingTestCase(stderr, test_) } - numscriptFileName := strings.TrimSuffix(specsFilePath, ".specs.json") + // Stats + return printFilesStats(stdout, allTests) - numscriptContent, err := os.ReadFile(numscriptFileName) - if err != nil { - _, _ = os.Stderr.Write([]byte(err.Error())) - os.Exit(1) - } +} - parseResult := parser.Parse(string(numscriptContent)) +func runRawSpec(stdout io.Writer, stderr io.Writer, rawSpec rawSpec) (specs_format.Specs, specs_format.SpecsResult, bool) { + parseResult := parser.Parse(rawSpec.NumscriptContent) if len(parseResult.Errors) != 0 { for _, err := range parseResult.Errors { - showErr(numscriptFileName, string(numscriptContent), err) + showErr(stderr, rawSpec.NumscriptPath, rawSpec.NumscriptContent, err) } - os.Exit(1) - } - - specsFileContent, err := os.ReadFile(specsFilePath) - if err != nil { - _, _ = os.Stderr.Write([]byte(err.Error())) - os.Exit(1) + return specs_format.Specs{}, specs_format.SpecsResult{}, false } var specs specs_format.Specs - err = json.Unmarshal([]byte(specsFileContent), &specs) + err := json.Unmarshal(rawSpec.SpecsFileContent, &specs) if err != nil { - _, _ = os.Stderr.Write([]byte(ansi.ColorRed(fmt.Sprintf("\nError: %s\n\n", specsFilePath)))) - _, _ = os.Stderr.Write([]byte(err.Error() + "\n")) - os.Exit(1) + _, _ = stderr.Write([]byte(ansi.ColorRed(fmt.Sprintf("\nError: %s.specs.json\n\n", rawSpec.NumscriptPath)))) + _, _ = stderr.Write([]byte(err.Error() + "\n")) + return specs_format.Specs{}, specs_format.SpecsResult{}, false } out, iErr := specs_format.Check(parseResult.Value, specs) + if iErr != nil { - showErr(numscriptFileName, string(numscriptContent), iErr) - os.Exit(1) + showErr(stderr, rawSpec.NumscriptPath, rawSpec.NumscriptContent, iErr) + return specs_format.Specs{}, specs_format.SpecsResult{}, false } if out.Total == 0 { - fmt.Println(ansi.ColorRed("Empty test suite: " + specsFilePath)) - os.Exit(1) + fmt.Fprintln(stdout, ansi.ColorRed("Empty test suite: "+rawSpec.SpecsPath)) + return specs_format.Specs{}, specs_format.SpecsResult{}, false } else if out.Failing == 0 { testsCount := ansi.ColorBrightBlack(fmt.Sprintf("(%d tests)", out.Total)) - fmt.Printf("%s %s %s\n", ansi.ColorGreen("✓"), numscriptFileName, testsCount) + fmt.Fprintf(stdout, "%s %s %s\n", ansi.ColorGreen("✓"), rawSpec.NumscriptPath, testsCount) } else { failedTestsCount := ansi.ColorRed(fmt.Sprintf("%d failed", out.Failing)) testsCount := ansi.ColorBrightBlack(fmt.Sprintf("(%d tests | %s)", out.Total, failedTestsCount)) - fmt.Printf("%s %s %s\n", ansi.ColorRed("❯"), numscriptFileName, testsCount) + fmt.Fprintf(stdout, "%s %s %s\n", ansi.ColorRed("❯"), rawSpec.NumscriptPath, testsCount) for _, result := range out.Cases { if result.Pass { continue } - fmt.Printf(" %s %s\n", ansi.ColorRed("×"), result.It) + fmt.Fprintf(stdout, " %s %s\n", ansi.ColorRed("×"), result.It) } - } - return specs, out + return specs, out, true } -type testResult struct { - Specs specs_format.Specs - File string - Result specs_format.TestCaseResult +func showDiff(w io.Writer, expected_ any, got_ any) { + dmp := diffmatchpatch.New() + + expected, _ := json.MarshalIndent(expected_, "", " ") + actual, _ := json.MarshalIndent(got_, "", " ") + + aChars, bChars, lineArray := dmp.DiffLinesToChars(string(expected), string(actual)) + diffs := dmp.DiffMain(aChars, bChars, true) + diffs = dmp.DiffCharsToLines(diffs, lineArray) + + for _, diff := range diffs { + lines := strings.Split(diff.Text, "\n") + for _, line := range lines { + if line == "" { + continue + } + switch diff.Type { + case diffmatchpatch.DiffDelete: + fmt.Fprintln(w, ansi.ColorGreen("- "+line)) + case diffmatchpatch.DiffInsert: + fmt.Fprintln(w, ansi.ColorRed("+ "+line)) + case diffmatchpatch.DiffEqual: + fmt.Fprintln(w, ansi.ColorBrightBlack(" "+line)) + } + } + } } -func testPaths() { - testFiles := 0 - failedTestFiles := 0 +func showFailingTestCase(w io.Writer, testResult testResult) { + if testResult.Result.Pass { + return + } - var allTests []testResult - for _, path := range opts.paths { - path = strings.TrimSuffix(path, "/") + specsFilePath := testResult.File + result := testResult.Result - glob := fmt.Sprintf(path + "/*.num.specs.json") + fmt.Fprint(w, "\n\n") - files, err := filepath.Glob(glob) - if err != nil { - panic(err) - } - testFiles += len(files) + failColor := ansi.Compose(ansi.BgRed, ansi.ColorLight, ansi.Bold) + fmt.Fprint(w, failColor(" FAIL ")) + fmt.Fprintln(w, ansi.ColorRed(" "+specsFilePath+" > "+result.It)) - if len(files) == 0 { - _, _ = os.Stderr.Write([]byte(ansi.ColorRed("No specs files found\n"))) - os.Exit(1) - } + showGiven := len(result.Balances) != 0 || len(result.Meta) != 0 || len(result.Vars) != 0 + if showGiven { + fmt.Fprintln(w, ansi.Underline("\nGIVEN:")) + } - for _, file := range files { - specs, out := test(file) + if len(result.Balances) != 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, result.Balances.PrettyPrint()) + fmt.Fprintln(w) + } - for _, testCase := range out.Cases { - allTests = append(allTests, testResult{ - Specs: specs, - File: file, - Result: testCase, - }) - } + if len(result.Meta) != 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, result.Meta.PrettyPrint()) + fmt.Fprintln(w) + } - // Count tests - isTestFailed := slices.ContainsFunc(out.Cases, func(tc specs_format.TestCaseResult) bool { - return tc.Pass - }) - if isTestFailed { - failedTestFiles += 1 - } - } + if len(result.Vars) != 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, utils.CsvPrettyMap("Name", "Value", result.Vars)) + fmt.Fprintln(w) } - for _, test_ := range allTests { - rerun := showFailingTestCase(test_) - if rerun { - fmt.Print("\033[H\033[2J") - testPaths() - return - } + fmt.Fprintln(w) + fmt.Fprintln(w, ansi.ColorGreen("- Expected")) + fmt.Fprintln(w, ansi.ColorRed("+ Received\n")) + + for _, failedAssertion := range result.FailedAssertions { + fmt.Fprintln(w, ansi.Underline(failedAssertion.Assertion)) + fmt.Fprintln(w) + showDiff(w, failedAssertion.Expected, failedAssertion.Got) } +} - // Stats - printFilesStats(allTests) +// TODO take writer +func showErr(stderr io.Writer, filename string, script string, err interpreter.InterpreterError) { + rng := err.GetRange() + + errFile := fmt.Sprintf("\nError: %s:%d:%d\n\n", filename, rng.Start.Line+1, rng.Start.Character+1) + _, _ = stderr.Write([]byte(ansi.ColorRed(errFile))) + _, _ = stderr.Write([]byte(err.Error() + "\n\n")) + if rng.Start != rng.End { + _, _ = stderr.Write([]byte("\n")) + _, _ = stderr.Write([]byte(rng.ShowOnSource(script) + "\n")) + } } -func printFilesStats(allTests []testResult) { +type testResult struct { + Specs specs_format.Specs + File string + Result specs_format.TestCaseResult +} + +func printFilesStats(w io.Writer, allTests []testResult) bool { failedTests := utils.Filter(allTests, func(t testResult) bool { return !t.Result.Pass }) @@ -315,7 +270,7 @@ func printFilesStats(allTests []testResult) { return ansi.ColorBrightBlack(fmt.Sprintf(" %*s ", maxLen, s)) } - fmt.Println() + fmt.Fprintln(w) // Files stats { @@ -340,10 +295,10 @@ func printFilesStats(allTests []testResult) { } testFilesUI := strings.Join(testFilesUIParts, ansi.ColorBrightBlack(" | ")) totalTestFilesUI := ansi.ColorBrightBlack(fmt.Sprintf("(%d)", filesCount)) - fmt.Print(paddedLabel(testFilesLabel) + " " + testFilesUI + " " + totalTestFilesUI) + fmt.Fprint(w, paddedLabel(testFilesLabel)+" "+testFilesUI+" "+totalTestFilesUI) } - fmt.Println() + fmt.Fprintln(w) // Tests stats { @@ -367,22 +322,27 @@ func printFilesStats(allTests []testResult) { testsUI := strings.Join(testUIParts, ansi.ColorBrightBlack(" | ")) totalTestsUI := ansi.ColorBrightBlack(fmt.Sprintf("(%d)", testsCount)) - fmt.Println(paddedLabel(testsLabel) + " " + testsUI + " " + totalTestsUI) + fmt.Fprintln(w, paddedLabel(testsLabel)+" "+testsUI+" "+totalTestsUI) - if failedTestsCount != 0 { - os.Exit(1) - } + return failedTestsCount == 0 } } type testArgs struct { - paths []string - interactive bool + paths []string } var opts = testArgs{} +func runTestCmd() { + files := readSpecsFiles() + pass := runRawSpecs(os.Stdout, os.Stderr, files) + if !pass { + os.Exit(1) + } +} + func getTestCmd() *cobra.Command { cmd := &cobra.Command{ @@ -399,15 +359,9 @@ Defaults to "." if there are no given paths`, } opts.paths = paths - testPaths() + runTestCmd() }, } - // A poor man's feature flag - // that's a post-mvp feature so we'll keep it as dead code for now - if false { - cmd.Flags().BoolVar(&opts.interactive, "experimental-interactive", false, "Interactively update the expectations with the received value") - } - return cmd } diff --git a/internal/cmd/test_test.go b/internal/cmd/test_test.go index 6d64eef8..26ca4daf 100644 --- a/internal/cmd/test_test.go +++ b/internal/cmd/test_test.go @@ -5,14 +5,70 @@ import ( "testing" "github.com/gkampitakis/go-snaps/snaps" + "github.com/stretchr/testify/require" ) func TestShowDiff(t *testing.T) { var buf bytes.Buffer showDiff( &buf, - map[string]any{"x": 42}, - map[string]any{"x": 100}, + map[string]any{ + "common": "ok", + "x": 42, + }, + map[string]any{ + "common": "ok", + "x": 100, + }, ) snaps.MatchSnapshot(t, buf.String()) } + +func TestSingleTest(t *testing.T) { + var out bytes.Buffer + + script := ` + send [USD/2 100] ( + source = @world + destination = @dest + ) + ` + + specs := ` + { + "testCases": [ + { + "it": "tfailing", + "expect.postings": [{ + "source": "wrong-source", + "destination": "dest", + "asset": "USD/2", + "amount": 100 + }] + }, + { + "it": "tpassing", + "expect.postings": [{ + "source": "world", + "destination": "dest", + "asset": "USD/2", + "amount": 100 + }] + } + ] + } + ` + + success := runRawSpecs(&out, &out, []rawSpec{ + { + NumscriptPath: "exmaple.num", + SpecsPath: "example.num.specs.json", + NumscriptContent: script, + SpecsFileContent: []byte(specs), + }, + }) + + require.False(t, success) + + snaps.MatchSnapshot(t, out.String()) +} From a7a228509193e148e253acdc63ca3c5415d13c2a Mon Sep 17 00:00:00 2001 From: ascandone Date: Wed, 16 Jul 2025 16:14:37 +0200 Subject: [PATCH 46/59] more tests --- internal/cmd/__snapshots__/test_test.snap | 54 +++++++++++++++++++++- internal/cmd/test.go | 1 + internal/cmd/test_test.go | 56 ++++++++++++++++++++++- 3 files changed, 109 insertions(+), 2 deletions(-) diff --git a/internal/cmd/__snapshots__/test_test.snap b/internal/cmd/__snapshots__/test_test.snap index c72b0ab7..6d401586 100755 --- a/internal/cmd/__snapshots__/test_test.snap +++ b/internal/cmd/__snapshots__/test_test.snap @@ -9,7 +9,7 @@ --- [TestSingleTest - 1] -❯ exmaple.num (2 tests | 1 failed) +❯ example.num (2 tests | 1 failed) × tfailing @@ -30,6 +30,58 @@  }  ] + + Test files  1 failed (1) + Tests  1 failed | 1 passed (2) + +--- + +[TestComplexAssertions - 1] +❯ example.num (2 tests | 1 failed) + × send when there are enough funds + + + FAIL  example.num.specs.json > send when there are enough funds + +GIVEN: + +| Account | Asset | Balance | +| alice | USD/2 | 9999 | + + +- Expected ++ Received + +expect.missingFunds + +- true ++ false + +expect.volumes + + { + "alice": { +- "USD/2": -100 ++ "USD/2": 9899 + }, + "dest": { +- "USD/2": 1 ++ "USD/2": 100 + } + } + +expect.movements + + { + "alice": { + "dest": { +- "EUR": 100 ++ "USD/2": 100 + } + } + } + +  Test files  1 failed (1)  Tests  1 failed | 1 passed (2) diff --git a/internal/cmd/test.go b/internal/cmd/test.go index a685e21b..71c4e1d7 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -234,6 +234,7 @@ func showFailingTestCase(w io.Writer, testResult testResult) { fmt.Fprintln(w, ansi.Underline(failedAssertion.Assertion)) fmt.Fprintln(w) showDiff(w, failedAssertion.Expected, failedAssertion.Got) + fmt.Fprintln(w) } } diff --git a/internal/cmd/test_test.go b/internal/cmd/test_test.go index 26ca4daf..34f5f4ea 100644 --- a/internal/cmd/test_test.go +++ b/internal/cmd/test_test.go @@ -61,7 +61,61 @@ func TestSingleTest(t *testing.T) { success := runRawSpecs(&out, &out, []rawSpec{ { - NumscriptPath: "exmaple.num", + NumscriptPath: "example.num", + SpecsPath: "example.num.specs.json", + NumscriptContent: script, + SpecsFileContent: []byte(specs), + }, + }) + + require.False(t, success) + + snaps.MatchSnapshot(t, out.String()) +} + +func TestComplexAssertions(t *testing.T) { + var out bytes.Buffer + + script := ` + send [USD/2 100] ( + source = @alice + destination = @dest + ) + ` + + specs := ` + { + "testCases": [ + { + "it": "send when there are enough funds", + "balances": { + "alice": { "USD/2": 9999 } + }, + "expect.volumes": { + "alice": { "USD/2": -100 }, + "dest": { "USD/2": 1 } + }, + "expect.movements": { + "alice": { + "dest": { "EUR": 100 } + } + }, + "expect.missingFunds": true + }, + { + "it": "tpassing", + "balances": { + "alice": { "USD/2": 0 } + }, + "expect.missingFunds": true + } + ] + } + ` + + success := runRawSpecs(&out, &out, []rawSpec{ + { + NumscriptPath: "example.num", SpecsPath: "example.num.specs.json", NumscriptContent: script, SpecsFileContent: []byte(specs), From da404edc5b576591d255f1a7f2a0d07f77ba3878 Mon Sep 17 00:00:00 2001 From: ascandone Date: Wed, 16 Jul 2025 16:15:16 +0200 Subject: [PATCH 47/59] removed comment --- internal/cmd/test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/cmd/test.go b/internal/cmd/test.go index 71c4e1d7..024db530 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -238,7 +238,6 @@ func showFailingTestCase(w io.Writer, testResult testResult) { } } -// TODO take writer func showErr(stderr io.Writer, filename string, script string, err interpreter.InterpreterError) { rng := err.GetRange() From 46ab23df9299b8e13f7a91d680ed5dd6a444443d Mon Sep 17 00:00:00 2001 From: ascandone Date: Wed, 16 Jul 2025 16:16:50 +0200 Subject: [PATCH 48/59] removed dead code --- internal/utils/utils.go | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 40e96ca7..018cddc5 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -1,7 +1,6 @@ package utils import ( - "encoding/json" "fmt" "math/big" ) @@ -38,15 +37,6 @@ func NonNeg(a *big.Int) *big.Int { return MaxBigInt(a, big.NewInt(0)) } -func Unmarshal[T any](raw json.RawMessage) (*T, error) { - var value T - err := json.Unmarshal(raw, &value) - if err != nil { - return nil, err - } - return &value, err -} - func Filter[T any](slice []T, predicate func(x T) bool) []T { var ret []T for _, x := range slice { @@ -57,15 +47,6 @@ func Filter[T any](slice []T, predicate func(x T) bool) []T { return ret } -func Map[T any, U any](slice []T, f func(x T) U) []U { - // TODO make - var ret []U - for _, x := range slice { - ret = append(ret, f(x)) - } - return ret -} - func MapGetOrPutDefault[T any](m map[string]T, key string, getDefault func() T) T { lookup, ok := m[key] if !ok { From 1618b8bbc9e88a2f3abfb77686efbfaf3979a8b1 Mon Sep 17 00:00:00 2001 From: ascandone Date: Wed, 16 Jul 2025 16:27:57 +0200 Subject: [PATCH 49/59] edit gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index f03c235a..a87420c6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ dist/ coverage.* + +.direnv From 856a89bc330b1bc26e7772b36166d9b114274b07 Mon Sep 17 00:00:00 2001 From: ascandone Date: Wed, 16 Jul 2025 16:33:40 +0200 Subject: [PATCH 50/59] more tests --- internal/cmd/__snapshots__/test_test.snap | 50 ++++++++++++++ internal/cmd/test_test.go | 84 +++++++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/internal/cmd/__snapshots__/test_test.snap b/internal/cmd/__snapshots__/test_test.snap index 6d401586..e1769f17 100755 --- a/internal/cmd/__snapshots__/test_test.snap +++ b/internal/cmd/__snapshots__/test_test.snap @@ -86,3 +86,53 @@ GIVEN:  Tests  1 failed | 1 passed (2) --- + +[TestNoFilesErrr - 1] +No specs files found + +--- + +[TestParseErrSpecs - 1] + +Error: example.num.specs.json + +invalid character 'o' in literal null (expecting 'u') + +--- + +[TestSchemaErrSpecs - 1] + +Error: example.num.specs.json + +json: cannot unmarshal number into Go struct field Specs.balances of type interpreter.Balances + +--- + +[TestNumscriptParseErr - 1] + +Error: example.num:1:1 + +token recognition error at: '!' + + +Error: example.num:1:5 + +mismatched input '' expecting '(' + + + 0 | !err + | ~~~~ + +--- + +[TestRuntimeErr - 1] + +Error: example.num:1:29 + +Invalid value received. Expecting value of type account (got ops! instead) + + + 0 | send [USD/2 100] ( source = "ops!" destination = @world) + | ~~~~~~ + +--- diff --git a/internal/cmd/test_test.go b/internal/cmd/test_test.go index 34f5f4ea..a9902cd9 100644 --- a/internal/cmd/test_test.go +++ b/internal/cmd/test_test.go @@ -126,3 +126,87 @@ func TestComplexAssertions(t *testing.T) { snaps.MatchSnapshot(t, out.String()) } + +func TestNoFilesErrr(t *testing.T) { + var out bytes.Buffer + success := runRawSpecs(&out, &out, []rawSpec{}) + require.False(t, success) + snaps.MatchSnapshot(t, out.String()) +} + +func TestParseErrSpecs(t *testing.T) { + var out bytes.Buffer + + success := runRawSpecs(&out, &out, []rawSpec{ + { + NumscriptPath: "example.num", + SpecsPath: "example.num.specs.json", + NumscriptContent: "", + SpecsFileContent: []byte(` + not a json + `), + }, + }) + require.False(t, success) + snaps.MatchSnapshot(t, out.String()) +} + +func TestSchemaErrSpecs(t *testing.T) { + var out bytes.Buffer + + success := runRawSpecs(&out, &out, []rawSpec{ + { + NumscriptPath: "example.num", + SpecsPath: "example.num.specs.json", + NumscriptContent: "", + SpecsFileContent: []byte(` + { "balances": 42 } + `), + }, + }) + require.False(t, success) + snaps.MatchSnapshot(t, out.String()) +} + +func TestNumscriptParseErr(t *testing.T) { + var out bytes.Buffer + + success := runRawSpecs(&out, &out, []rawSpec{ + { + NumscriptPath: "example.num", + SpecsPath: "example.num.specs.json", + NumscriptContent: "!err", + SpecsFileContent: []byte(` + { } + `), + }, + }) + require.False(t, success) + snaps.MatchSnapshot(t, out.String()) +} + +func TestRuntimeErr(t *testing.T) { + var out bytes.Buffer + + specs := ` + { + "testCases": [ + { + "it": "runs", + "expect.missingFunds": false + } + ] + } + ` + + success := runRawSpecs(&out, &out, []rawSpec{ + { + NumscriptPath: "example.num", + SpecsPath: "example.num.specs.json", + NumscriptContent: `send [USD/2 100] ( source = "ops!" destination = @world)`, + SpecsFileContent: []byte(specs), + }, + }) + require.False(t, success) + snaps.MatchSnapshot(t, out.String()) +} From db16df74bae59c09243f0e5de9af90792d2c0305 Mon Sep 17 00:00:00 2001 From: ascandone Date: Wed, 16 Jul 2025 16:35:13 +0200 Subject: [PATCH 51/59] fix lint errs --- internal/cmd/test.go | 60 ++++++++++++++++++++++---------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/internal/cmd/test.go b/internal/cmd/test.go index 024db530..3e3fc5a7 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -138,23 +138,23 @@ func runRawSpec(stdout io.Writer, stderr io.Writer, rawSpec rawSpec) (specs_form } if out.Total == 0 { - fmt.Fprintln(stdout, ansi.ColorRed("Empty test suite: "+rawSpec.SpecsPath)) + _, _ = fmt.Fprintln(stdout, ansi.ColorRed("Empty test suite: "+rawSpec.SpecsPath)) return specs_format.Specs{}, specs_format.SpecsResult{}, false } else if out.Failing == 0 { testsCount := ansi.ColorBrightBlack(fmt.Sprintf("(%d tests)", out.Total)) - fmt.Fprintf(stdout, "%s %s %s\n", ansi.ColorGreen("✓"), rawSpec.NumscriptPath, testsCount) + _, _ = fmt.Fprintf(stdout, "%s %s %s\n", ansi.ColorGreen("✓"), rawSpec.NumscriptPath, testsCount) } else { failedTestsCount := ansi.ColorRed(fmt.Sprintf("%d failed", out.Failing)) testsCount := ansi.ColorBrightBlack(fmt.Sprintf("(%d tests | %s)", out.Total, failedTestsCount)) - fmt.Fprintf(stdout, "%s %s %s\n", ansi.ColorRed("❯"), rawSpec.NumscriptPath, testsCount) + _, _ = fmt.Fprintf(stdout, "%s %s %s\n", ansi.ColorRed("❯"), rawSpec.NumscriptPath, testsCount) for _, result := range out.Cases { if result.Pass { continue } - fmt.Fprintf(stdout, " %s %s\n", ansi.ColorRed("×"), result.It) + _, _ = fmt.Fprintf(stdout, " %s %s\n", ansi.ColorRed("×"), result.It) } } @@ -179,11 +179,11 @@ func showDiff(w io.Writer, expected_ any, got_ any) { } switch diff.Type { case diffmatchpatch.DiffDelete: - fmt.Fprintln(w, ansi.ColorGreen("- "+line)) + _, _ = fmt.Fprintln(w, ansi.ColorGreen("- "+line)) case diffmatchpatch.DiffInsert: - fmt.Fprintln(w, ansi.ColorRed("+ "+line)) + _, _ = fmt.Fprintln(w, ansi.ColorRed("+ "+line)) case diffmatchpatch.DiffEqual: - fmt.Fprintln(w, ansi.ColorBrightBlack(" "+line)) + _, _ = fmt.Fprintln(w, ansi.ColorBrightBlack(" "+line)) } } } @@ -197,44 +197,44 @@ func showFailingTestCase(w io.Writer, testResult testResult) { specsFilePath := testResult.File result := testResult.Result - fmt.Fprint(w, "\n\n") + _, _ = fmt.Fprint(w, "\n\n") failColor := ansi.Compose(ansi.BgRed, ansi.ColorLight, ansi.Bold) - fmt.Fprint(w, failColor(" FAIL ")) - fmt.Fprintln(w, ansi.ColorRed(" "+specsFilePath+" > "+result.It)) + _, _ = fmt.Fprint(w, failColor(" FAIL ")) + _, _ = fmt.Fprintln(w, ansi.ColorRed(" "+specsFilePath+" > "+result.It)) showGiven := len(result.Balances) != 0 || len(result.Meta) != 0 || len(result.Vars) != 0 if showGiven { - fmt.Fprintln(w, ansi.Underline("\nGIVEN:")) + _, _ = fmt.Fprintln(w, ansi.Underline("\nGIVEN:")) } if len(result.Balances) != 0 { - fmt.Fprintln(w) - fmt.Fprintln(w, result.Balances.PrettyPrint()) - fmt.Fprintln(w) + _, _ = fmt.Fprintln(w) + _, _ = fmt.Fprintln(w, result.Balances.PrettyPrint()) + _, _ = fmt.Fprintln(w) } if len(result.Meta) != 0 { - fmt.Fprintln(w) - fmt.Fprintln(w, result.Meta.PrettyPrint()) - fmt.Fprintln(w) + _, _ = fmt.Fprintln(w) + _, _ = fmt.Fprintln(w, result.Meta.PrettyPrint()) + _, _ = fmt.Fprintln(w) } if len(result.Vars) != 0 { - fmt.Fprintln(w) - fmt.Fprintln(w, utils.CsvPrettyMap("Name", "Value", result.Vars)) - fmt.Fprintln(w) + _, _ = fmt.Fprintln(w) + _, _ = fmt.Fprintln(w, utils.CsvPrettyMap("Name", "Value", result.Vars)) + _, _ = fmt.Fprintln(w) } - fmt.Fprintln(w) - fmt.Fprintln(w, ansi.ColorGreen("- Expected")) - fmt.Fprintln(w, ansi.ColorRed("+ Received\n")) + _, _ = fmt.Fprintln(w) + _, _ = fmt.Fprintln(w, ansi.ColorGreen("- Expected")) + _, _ = fmt.Fprintln(w, ansi.ColorRed("+ Received\n")) for _, failedAssertion := range result.FailedAssertions { - fmt.Fprintln(w, ansi.Underline(failedAssertion.Assertion)) - fmt.Fprintln(w) + _, _ = fmt.Fprintln(w, ansi.Underline(failedAssertion.Assertion)) + _, _ = fmt.Fprintln(w) showDiff(w, failedAssertion.Expected, failedAssertion.Got) - fmt.Fprintln(w) + _, _ = fmt.Fprintln(w) } } @@ -270,7 +270,7 @@ func printFilesStats(w io.Writer, allTests []testResult) bool { return ansi.ColorBrightBlack(fmt.Sprintf(" %*s ", maxLen, s)) } - fmt.Fprintln(w) + _, _ = fmt.Fprintln(w) // Files stats { @@ -295,10 +295,10 @@ func printFilesStats(w io.Writer, allTests []testResult) bool { } testFilesUI := strings.Join(testFilesUIParts, ansi.ColorBrightBlack(" | ")) totalTestFilesUI := ansi.ColorBrightBlack(fmt.Sprintf("(%d)", filesCount)) - fmt.Fprint(w, paddedLabel(testFilesLabel)+" "+testFilesUI+" "+totalTestFilesUI) + _, _ = fmt.Fprint(w, paddedLabel(testFilesLabel)+" "+testFilesUI+" "+totalTestFilesUI) } - fmt.Fprintln(w) + _, _ = fmt.Fprintln(w) // Tests stats { @@ -322,7 +322,7 @@ func printFilesStats(w io.Writer, allTests []testResult) bool { testsUI := strings.Join(testUIParts, ansi.ColorBrightBlack(" | ")) totalTestsUI := ansi.ColorBrightBlack(fmt.Sprintf("(%d)", testsCount)) - fmt.Fprintln(w, paddedLabel(testsLabel)+" "+testsUI+" "+totalTestsUI) + _, _ = fmt.Fprintln(w, paddedLabel(testsLabel)+" "+testsUI+" "+totalTestsUI) return failedTestsCount == 0 } From 32d6e05a3a2d9a13e658c658da8640e80e7d4c9d Mon Sep 17 00:00:00 2001 From: ascandone Date: Wed, 16 Jul 2025 16:48:07 +0200 Subject: [PATCH 52/59] removed unused code --- internal/cmd/test.go | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/internal/cmd/test.go b/internal/cmd/test.go index 3e3fc5a7..a32fb648 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -76,8 +76,6 @@ func runRawSpecs(stdout io.Writer, stderr io.Writer, rawSpecs []rawSpec) bool { return false } - failedTestFiles := 0 - var allTests []testResult for _, rawSpec := range rawSpecs { @@ -86,14 +84,6 @@ func runRawSpecs(stdout io.Writer, stderr io.Writer, rawSpecs []rawSpec) bool { return false } - // Count tests - isTestFailed := slices.ContainsFunc(out.Cases, func(tc specs_format.TestCaseResult) bool { - return tc.Pass - }) - if isTestFailed { - failedTestFiles += 1 - } - for _, caseResult := range out.Cases { allTests = append(allTests, testResult{ Specs: specs, From 6f9f25c722cc0e2444e56ccccf846f78cd1c9d27 Mon Sep 17 00:00:00 2001 From: ascandone Date: Wed, 16 Jul 2025 16:48:30 +0200 Subject: [PATCH 53/59] fix typo --- internal/cmd/__snapshots__/test_test.snap | 2 +- internal/cmd/test_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/cmd/__snapshots__/test_test.snap b/internal/cmd/__snapshots__/test_test.snap index e1769f17..0cfee566 100755 --- a/internal/cmd/__snapshots__/test_test.snap +++ b/internal/cmd/__snapshots__/test_test.snap @@ -87,7 +87,7 @@ GIVEN: --- -[TestNoFilesErrr - 1] +[TestNoFilesErr - 1] No specs files found  --- diff --git a/internal/cmd/test_test.go b/internal/cmd/test_test.go index a9902cd9..372700f7 100644 --- a/internal/cmd/test_test.go +++ b/internal/cmd/test_test.go @@ -127,7 +127,7 @@ func TestComplexAssertions(t *testing.T) { snaps.MatchSnapshot(t, out.String()) } -func TestNoFilesErrr(t *testing.T) { +func TestNoFilesErr(t *testing.T) { var out bytes.Buffer success := runRawSpecs(&out, &out, []rawSpec{}) require.False(t, success) From 29a351f9aa5ae03d72b85f55981e52dd57e81c07 Mon Sep 17 00:00:00 2001 From: ascandone Date: Thu, 17 Jul 2025 13:43:10 +0200 Subject: [PATCH 54/59] refactor: moved files in the specs_format module --- internal/cmd/test.go | 273 +----------------- .../__snapshots__/runner_test.snap} | 0 internal/specs_format/runner.go | 271 +++++++++++++++++ .../runner_test.go} | 19 +- 4 files changed, 285 insertions(+), 278 deletions(-) rename internal/{cmd/__snapshots__/test_test.snap => specs_format/__snapshots__/runner_test.snap} (100%) create mode 100644 internal/specs_format/runner.go rename internal/{cmd/test_test.go => specs_format/runner_test.go} (85%) diff --git a/internal/cmd/test.go b/internal/cmd/test.go index a32fb648..412f67ba 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -1,33 +1,17 @@ package cmd import ( - "encoding/json" - "fmt" - "io" "os" "path/filepath" - "slices" "strings" "github.com/formancehq/numscript/internal/ansi" - "github.com/formancehq/numscript/internal/interpreter" - "github.com/formancehq/numscript/internal/parser" "github.com/formancehq/numscript/internal/specs_format" - "github.com/formancehq/numscript/internal/utils" "github.com/spf13/cobra" - - "github.com/sergi/go-diff/diffmatchpatch" ) -type rawSpec struct { - NumscriptPath string - SpecsPath string - NumscriptContent string - SpecsFileContent []byte -} - -func readSpecsFiles() []rawSpec { - var specs []rawSpec +func readSpecsFiles() []specs_format.RawSpec { + var specs []specs_format.RawSpec for _, path := range opts.paths { path = strings.TrimSuffix(path, "/") @@ -58,7 +42,7 @@ func readSpecsFiles() []rawSpec { os.Exit(1) } - specs = append(specs, rawSpec{ + specs = append(specs, specs_format.RawSpec{ NumscriptPath: numscriptFileName, SpecsPath: specsFilePath, NumscriptContent: string(numscriptContent), @@ -70,255 +54,6 @@ func readSpecsFiles() []rawSpec { return specs } -func runRawSpecs(stdout io.Writer, stderr io.Writer, rawSpecs []rawSpec) bool { - if len(rawSpecs) == 0 { - _, _ = stderr.Write([]byte(ansi.ColorRed("No specs files found\n"))) - return false - } - - var allTests []testResult - - for _, rawSpec := range rawSpecs { - specs, out, ok := runRawSpec(stdout, stderr, rawSpec) - if !ok { - return false - } - - for _, caseResult := range out.Cases { - allTests = append(allTests, testResult{ - Specs: specs, - Result: caseResult, - File: rawSpec.SpecsPath, - }) - } - - } - - for _, test_ := range allTests { - showFailingTestCase(stderr, test_) - } - - // Stats - return printFilesStats(stdout, allTests) - -} - -func runRawSpec(stdout io.Writer, stderr io.Writer, rawSpec rawSpec) (specs_format.Specs, specs_format.SpecsResult, bool) { - parseResult := parser.Parse(rawSpec.NumscriptContent) - if len(parseResult.Errors) != 0 { - for _, err := range parseResult.Errors { - showErr(stderr, rawSpec.NumscriptPath, rawSpec.NumscriptContent, err) - } - return specs_format.Specs{}, specs_format.SpecsResult{}, false - } - - var specs specs_format.Specs - err := json.Unmarshal(rawSpec.SpecsFileContent, &specs) - if err != nil { - _, _ = stderr.Write([]byte(ansi.ColorRed(fmt.Sprintf("\nError: %s.specs.json\n\n", rawSpec.NumscriptPath)))) - _, _ = stderr.Write([]byte(err.Error() + "\n")) - return specs_format.Specs{}, specs_format.SpecsResult{}, false - } - - out, iErr := specs_format.Check(parseResult.Value, specs) - - if iErr != nil { - showErr(stderr, rawSpec.NumscriptPath, rawSpec.NumscriptContent, iErr) - return specs_format.Specs{}, specs_format.SpecsResult{}, false - } - - if out.Total == 0 { - _, _ = fmt.Fprintln(stdout, ansi.ColorRed("Empty test suite: "+rawSpec.SpecsPath)) - return specs_format.Specs{}, specs_format.SpecsResult{}, false - } else if out.Failing == 0 { - testsCount := ansi.ColorBrightBlack(fmt.Sprintf("(%d tests)", out.Total)) - _, _ = fmt.Fprintf(stdout, "%s %s %s\n", ansi.ColorGreen("✓"), rawSpec.NumscriptPath, testsCount) - } else { - failedTestsCount := ansi.ColorRed(fmt.Sprintf("%d failed", out.Failing)) - - testsCount := ansi.ColorBrightBlack(fmt.Sprintf("(%d tests | %s)", out.Total, failedTestsCount)) - _, _ = fmt.Fprintf(stdout, "%s %s %s\n", ansi.ColorRed("❯"), rawSpec.NumscriptPath, testsCount) - - for _, result := range out.Cases { - if result.Pass { - continue - } - - _, _ = fmt.Fprintf(stdout, " %s %s\n", ansi.ColorRed("×"), result.It) - } - } - - return specs, out, true -} - -func showDiff(w io.Writer, expected_ any, got_ any) { - dmp := diffmatchpatch.New() - - expected, _ := json.MarshalIndent(expected_, "", " ") - actual, _ := json.MarshalIndent(got_, "", " ") - - aChars, bChars, lineArray := dmp.DiffLinesToChars(string(expected), string(actual)) - diffs := dmp.DiffMain(aChars, bChars, true) - diffs = dmp.DiffCharsToLines(diffs, lineArray) - - for _, diff := range diffs { - lines := strings.Split(diff.Text, "\n") - for _, line := range lines { - if line == "" { - continue - } - switch diff.Type { - case diffmatchpatch.DiffDelete: - _, _ = fmt.Fprintln(w, ansi.ColorGreen("- "+line)) - case diffmatchpatch.DiffInsert: - _, _ = fmt.Fprintln(w, ansi.ColorRed("+ "+line)) - case diffmatchpatch.DiffEqual: - _, _ = fmt.Fprintln(w, ansi.ColorBrightBlack(" "+line)) - } - } - } -} - -func showFailingTestCase(w io.Writer, testResult testResult) { - if testResult.Result.Pass { - return - } - - specsFilePath := testResult.File - result := testResult.Result - - _, _ = fmt.Fprint(w, "\n\n") - - failColor := ansi.Compose(ansi.BgRed, ansi.ColorLight, ansi.Bold) - _, _ = fmt.Fprint(w, failColor(" FAIL ")) - _, _ = fmt.Fprintln(w, ansi.ColorRed(" "+specsFilePath+" > "+result.It)) - - showGiven := len(result.Balances) != 0 || len(result.Meta) != 0 || len(result.Vars) != 0 - if showGiven { - _, _ = fmt.Fprintln(w, ansi.Underline("\nGIVEN:")) - } - - if len(result.Balances) != 0 { - _, _ = fmt.Fprintln(w) - _, _ = fmt.Fprintln(w, result.Balances.PrettyPrint()) - _, _ = fmt.Fprintln(w) - } - - if len(result.Meta) != 0 { - _, _ = fmt.Fprintln(w) - _, _ = fmt.Fprintln(w, result.Meta.PrettyPrint()) - _, _ = fmt.Fprintln(w) - } - - if len(result.Vars) != 0 { - _, _ = fmt.Fprintln(w) - _, _ = fmt.Fprintln(w, utils.CsvPrettyMap("Name", "Value", result.Vars)) - _, _ = fmt.Fprintln(w) - } - - _, _ = fmt.Fprintln(w) - _, _ = fmt.Fprintln(w, ansi.ColorGreen("- Expected")) - _, _ = fmt.Fprintln(w, ansi.ColorRed("+ Received\n")) - - for _, failedAssertion := range result.FailedAssertions { - _, _ = fmt.Fprintln(w, ansi.Underline(failedAssertion.Assertion)) - _, _ = fmt.Fprintln(w) - showDiff(w, failedAssertion.Expected, failedAssertion.Got) - _, _ = fmt.Fprintln(w) - } -} - -func showErr(stderr io.Writer, filename string, script string, err interpreter.InterpreterError) { - rng := err.GetRange() - - errFile := fmt.Sprintf("\nError: %s:%d:%d\n\n", filename, rng.Start.Line+1, rng.Start.Character+1) - _, _ = stderr.Write([]byte(ansi.ColorRed(errFile))) - _, _ = stderr.Write([]byte(err.Error() + "\n\n")) - - if rng.Start != rng.End { - _, _ = stderr.Write([]byte("\n")) - _, _ = stderr.Write([]byte(rng.ShowOnSource(script) + "\n")) - } -} - -type testResult struct { - Specs specs_format.Specs - File string - Result specs_format.TestCaseResult -} - -func printFilesStats(w io.Writer, allTests []testResult) bool { - failedTests := utils.Filter(allTests, func(t testResult) bool { - return !t.Result.Pass - }) - - testFilesLabel := "Test files" - testsLabel := "Tests" - - paddedLabel := func(s string) string { - maxLen := max(len(testFilesLabel), len(testsLabel)) // yeah, ok, this could be hardcoded, I know - return ansi.ColorBrightBlack(fmt.Sprintf(" %*s ", maxLen, s)) - } - - _, _ = fmt.Fprintln(w) - - // Files stats - { - filesCount := len(slices.CompactFunc(allTests, func(t1 testResult, t2 testResult) bool { - return t1.File == t2.File - })) - failedTestsFilesCount := len(slices.CompactFunc(failedTests, func(t1 testResult, t2 testResult) bool { - return t1.File == t2.File - })) - passedTestsFilesCount := filesCount - failedTestsFilesCount - - var testFilesUIParts []string - if failedTestsFilesCount != 0 { - testFilesUIParts = append(testFilesUIParts, - ansi.Compose(ansi.ColorBrightRed, ansi.Bold)(fmt.Sprintf("%d failed", failedTestsFilesCount)), - ) - } - if passedTestsFilesCount != 0 { - testFilesUIParts = append(testFilesUIParts, - ansi.Compose(ansi.ColorBrightGreen, ansi.Bold)(fmt.Sprintf("%d passed", passedTestsFilesCount)), - ) - } - testFilesUI := strings.Join(testFilesUIParts, ansi.ColorBrightBlack(" | ")) - totalTestFilesUI := ansi.ColorBrightBlack(fmt.Sprintf("(%d)", filesCount)) - _, _ = fmt.Fprint(w, paddedLabel(testFilesLabel)+" "+testFilesUI+" "+totalTestFilesUI) - } - - _, _ = fmt.Fprintln(w) - - // Tests stats - { - - testsCount := len(allTests) - failedTestsCount := len(failedTests) - passedTestsCount := testsCount - failedTestsCount - - var testUIParts []string - if failedTestsCount != 0 { - testUIParts = append(testUIParts, - ansi.Compose(ansi.ColorBrightRed, ansi.Bold)(fmt.Sprintf("%d failed", failedTestsCount)), - ) - } - if passedTestsCount != 0 { - testUIParts = append(testUIParts, - ansi.Compose(ansi.ColorBrightGreen, ansi.Bold)(fmt.Sprintf("%d passed", passedTestsCount)), - ) - } - - testsUI := strings.Join(testUIParts, ansi.ColorBrightBlack(" | ")) - totalTestsUI := ansi.ColorBrightBlack(fmt.Sprintf("(%d)", testsCount)) - - _, _ = fmt.Fprintln(w, paddedLabel(testsLabel)+" "+testsUI+" "+totalTestsUI) - - return failedTestsCount == 0 - } - -} - type testArgs struct { paths []string } @@ -327,7 +62,7 @@ var opts = testArgs{} func runTestCmd() { files := readSpecsFiles() - pass := runRawSpecs(os.Stdout, os.Stderr, files) + pass := specs_format.RunSpecs(os.Stdout, os.Stderr, files) if !pass { os.Exit(1) } diff --git a/internal/cmd/__snapshots__/test_test.snap b/internal/specs_format/__snapshots__/runner_test.snap similarity index 100% rename from internal/cmd/__snapshots__/test_test.snap rename to internal/specs_format/__snapshots__/runner_test.snap diff --git a/internal/specs_format/runner.go b/internal/specs_format/runner.go new file mode 100644 index 00000000..72df25c8 --- /dev/null +++ b/internal/specs_format/runner.go @@ -0,0 +1,271 @@ +package specs_format + +import ( + "encoding/json" + "fmt" + "io" + "slices" + "strings" + + "github.com/formancehq/numscript/internal/ansi" + "github.com/formancehq/numscript/internal/interpreter" + "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/utils" + "github.com/sergi/go-diff/diffmatchpatch" +) + +type RawSpec struct { + NumscriptPath string + SpecsPath string + NumscriptContent string + SpecsFileContent []byte +} + +type TestResult struct { + Specs Specs + File string + Result TestCaseResult +} + +func RunSpecs(stdout io.Writer, stderr io.Writer, rawSpecs []RawSpec) bool { + if len(rawSpecs) == 0 { + _, _ = stderr.Write([]byte(ansi.ColorRed("No specs files found\n"))) + return false + } + + var allTests []TestResult + + for _, rawSpec := range rawSpecs { + specs, out, ok := runRawSpec(stdout, stderr, rawSpec) + if !ok { + return false + } + + for _, caseResult := range out.Cases { + allTests = append(allTests, TestResult{ + Specs: specs, + Result: caseResult, + File: rawSpec.SpecsPath, + }) + } + + } + + for _, test_ := range allTests { + showFailingTestCase(stderr, test_) + } + + // Stats + return printFilesStats(stdout, allTests) + +} + +func runRawSpec(stdout io.Writer, stderr io.Writer, rawSpec RawSpec) (Specs, SpecsResult, bool) { + parseResult := parser.Parse(rawSpec.NumscriptContent) + if len(parseResult.Errors) != 0 { + for _, err := range parseResult.Errors { + showErr(stderr, rawSpec.NumscriptPath, rawSpec.NumscriptContent, err) + } + return Specs{}, SpecsResult{}, false + } + + var specs Specs + err := json.Unmarshal(rawSpec.SpecsFileContent, &specs) + if err != nil { + _, _ = stderr.Write([]byte(ansi.ColorRed(fmt.Sprintf("\nError: %s.specs.json\n\n", rawSpec.NumscriptPath)))) + _, _ = stderr.Write([]byte(err.Error() + "\n")) + return Specs{}, SpecsResult{}, false + } + + out, iErr := Check(parseResult.Value, specs) + + if iErr != nil { + showErr(stderr, rawSpec.NumscriptPath, rawSpec.NumscriptContent, iErr) + return Specs{}, SpecsResult{}, false + } + + if out.Total == 0 { + _, _ = fmt.Fprintln(stdout, ansi.ColorRed("Empty test suite: "+rawSpec.SpecsPath)) + return Specs{}, SpecsResult{}, false + } else if out.Failing == 0 { + testsCount := ansi.ColorBrightBlack(fmt.Sprintf("(%d tests)", out.Total)) + _, _ = fmt.Fprintf(stdout, "%s %s %s\n", ansi.ColorGreen("✓"), rawSpec.NumscriptPath, testsCount) + } else { + failedTestsCount := ansi.ColorRed(fmt.Sprintf("%d failed", out.Failing)) + + testsCount := ansi.ColorBrightBlack(fmt.Sprintf("(%d tests | %s)", out.Total, failedTestsCount)) + _, _ = fmt.Fprintf(stdout, "%s %s %s\n", ansi.ColorRed("❯"), rawSpec.NumscriptPath, testsCount) + + for _, result := range out.Cases { + if result.Pass { + continue + } + + _, _ = fmt.Fprintf(stdout, " %s %s\n", ansi.ColorRed("×"), result.It) + } + } + + return specs, out, true +} + +func ShowDiff(w io.Writer, expected_ any, got_ any) { + dmp := diffmatchpatch.New() + + expected, _ := json.MarshalIndent(expected_, "", " ") + actual, _ := json.MarshalIndent(got_, "", " ") + + aChars, bChars, lineArray := dmp.DiffLinesToChars(string(expected), string(actual)) + diffs := dmp.DiffMain(aChars, bChars, true) + diffs = dmp.DiffCharsToLines(diffs, lineArray) + + for _, diff := range diffs { + lines := strings.Split(diff.Text, "\n") + for _, line := range lines { + if line == "" { + continue + } + switch diff.Type { + case diffmatchpatch.DiffDelete: + _, _ = fmt.Fprintln(w, ansi.ColorGreen("- "+line)) + case diffmatchpatch.DiffInsert: + _, _ = fmt.Fprintln(w, ansi.ColorRed("+ "+line)) + case diffmatchpatch.DiffEqual: + _, _ = fmt.Fprintln(w, ansi.ColorBrightBlack(" "+line)) + } + } + } +} + +func showFailingTestCase(w io.Writer, testResult TestResult) { + if testResult.Result.Pass { + return + } + + specsFilePath := testResult.File + result := testResult.Result + + _, _ = fmt.Fprint(w, "\n\n") + + failColor := ansi.Compose(ansi.BgRed, ansi.ColorLight, ansi.Bold) + _, _ = fmt.Fprint(w, failColor(" FAIL ")) + _, _ = fmt.Fprintln(w, ansi.ColorRed(" "+specsFilePath+" > "+result.It)) + + showGiven := len(result.Balances) != 0 || len(result.Meta) != 0 || len(result.Vars) != 0 + if showGiven { + _, _ = fmt.Fprintln(w, ansi.Underline("\nGIVEN:")) + } + + if len(result.Balances) != 0 { + _, _ = fmt.Fprintln(w) + _, _ = fmt.Fprintln(w, result.Balances.PrettyPrint()) + _, _ = fmt.Fprintln(w) + } + + if len(result.Meta) != 0 { + _, _ = fmt.Fprintln(w) + _, _ = fmt.Fprintln(w, result.Meta.PrettyPrint()) + _, _ = fmt.Fprintln(w) + } + + if len(result.Vars) != 0 { + _, _ = fmt.Fprintln(w) + _, _ = fmt.Fprintln(w, utils.CsvPrettyMap("Name", "Value", result.Vars)) + _, _ = fmt.Fprintln(w) + } + + _, _ = fmt.Fprintln(w) + _, _ = fmt.Fprintln(w, ansi.ColorGreen("- Expected")) + _, _ = fmt.Fprintln(w, ansi.ColorRed("+ Received\n")) + + for _, failedAssertion := range result.FailedAssertions { + _, _ = fmt.Fprintln(w, ansi.Underline(failedAssertion.Assertion)) + _, _ = fmt.Fprintln(w) + ShowDiff(w, failedAssertion.Expected, failedAssertion.Got) + _, _ = fmt.Fprintln(w) + } +} + +func showErr(stderr io.Writer, filename string, script string, err interpreter.InterpreterError) { + rng := err.GetRange() + + errFile := fmt.Sprintf("\nError: %s:%d:%d\n\n", filename, rng.Start.Line+1, rng.Start.Character+1) + _, _ = stderr.Write([]byte(ansi.ColorRed(errFile))) + _, _ = stderr.Write([]byte(err.Error() + "\n\n")) + + if rng.Start != rng.End { + _, _ = stderr.Write([]byte("\n")) + _, _ = stderr.Write([]byte(rng.ShowOnSource(script) + "\n")) + } +} + +func printFilesStats(w io.Writer, allTests []TestResult) bool { + failedTests := utils.Filter(allTests, func(t TestResult) bool { + return !t.Result.Pass + }) + + testFilesLabel := "Test files" + testsLabel := "Tests" + + paddedLabel := func(s string) string { + maxLen := max(len(testFilesLabel), len(testsLabel)) // yeah, ok, this could be hardcoded, I know + return ansi.ColorBrightBlack(fmt.Sprintf(" %*s ", maxLen, s)) + } + + _, _ = fmt.Fprintln(w) + + // Files stats + { + filesCount := len(slices.CompactFunc(allTests, func(t1 TestResult, t2 TestResult) bool { + return t1.File == t2.File + })) + failedTestsFilesCount := len(slices.CompactFunc(failedTests, func(t1 TestResult, t2 TestResult) bool { + return t1.File == t2.File + })) + passedTestsFilesCount := filesCount - failedTestsFilesCount + + var testFilesUIParts []string + if failedTestsFilesCount != 0 { + testFilesUIParts = append(testFilesUIParts, + ansi.Compose(ansi.ColorBrightRed, ansi.Bold)(fmt.Sprintf("%d failed", failedTestsFilesCount)), + ) + } + if passedTestsFilesCount != 0 { + testFilesUIParts = append(testFilesUIParts, + ansi.Compose(ansi.ColorBrightGreen, ansi.Bold)(fmt.Sprintf("%d passed", passedTestsFilesCount)), + ) + } + testFilesUI := strings.Join(testFilesUIParts, ansi.ColorBrightBlack(" | ")) + totalTestFilesUI := ansi.ColorBrightBlack(fmt.Sprintf("(%d)", filesCount)) + _, _ = fmt.Fprint(w, paddedLabel(testFilesLabel)+" "+testFilesUI+" "+totalTestFilesUI) + } + + _, _ = fmt.Fprintln(w) + + // Tests stats + { + + testsCount := len(allTests) + failedTestsCount := len(failedTests) + passedTestsCount := testsCount - failedTestsCount + + var testUIParts []string + if failedTestsCount != 0 { + testUIParts = append(testUIParts, + ansi.Compose(ansi.ColorBrightRed, ansi.Bold)(fmt.Sprintf("%d failed", failedTestsCount)), + ) + } + if passedTestsCount != 0 { + testUIParts = append(testUIParts, + ansi.Compose(ansi.ColorBrightGreen, ansi.Bold)(fmt.Sprintf("%d passed", passedTestsCount)), + ) + } + + testsUI := strings.Join(testUIParts, ansi.ColorBrightBlack(" | ")) + totalTestsUI := ansi.ColorBrightBlack(fmt.Sprintf("(%d)", testsCount)) + + _, _ = fmt.Fprintln(w, paddedLabel(testsLabel)+" "+testsUI+" "+totalTestsUI) + + return failedTestsCount == 0 + } + +} diff --git a/internal/cmd/test_test.go b/internal/specs_format/runner_test.go similarity index 85% rename from internal/cmd/test_test.go rename to internal/specs_format/runner_test.go index 372700f7..21973bb0 100644 --- a/internal/cmd/test_test.go +++ b/internal/specs_format/runner_test.go @@ -1,16 +1,17 @@ -package cmd +package specs_format_test import ( "bytes" "testing" + "github.com/formancehq/numscript/internal/specs_format" "github.com/gkampitakis/go-snaps/snaps" "github.com/stretchr/testify/require" ) func TestShowDiff(t *testing.T) { var buf bytes.Buffer - showDiff( + specs_format.ShowDiff( &buf, map[string]any{ "common": "ok", @@ -59,7 +60,7 @@ func TestSingleTest(t *testing.T) { } ` - success := runRawSpecs(&out, &out, []rawSpec{ + success := specs_format.RunSpecs(&out, &out, []specs_format.RawSpec{ { NumscriptPath: "example.num", SpecsPath: "example.num.specs.json", @@ -113,7 +114,7 @@ func TestComplexAssertions(t *testing.T) { } ` - success := runRawSpecs(&out, &out, []rawSpec{ + success := specs_format.RunSpecs(&out, &out, []specs_format.RawSpec{ { NumscriptPath: "example.num", SpecsPath: "example.num.specs.json", @@ -129,7 +130,7 @@ func TestComplexAssertions(t *testing.T) { func TestNoFilesErr(t *testing.T) { var out bytes.Buffer - success := runRawSpecs(&out, &out, []rawSpec{}) + success := specs_format.RunSpecs(&out, &out, []specs_format.RawSpec{}) require.False(t, success) snaps.MatchSnapshot(t, out.String()) } @@ -137,7 +138,7 @@ func TestNoFilesErr(t *testing.T) { func TestParseErrSpecs(t *testing.T) { var out bytes.Buffer - success := runRawSpecs(&out, &out, []rawSpec{ + success := specs_format.RunSpecs(&out, &out, []specs_format.RawSpec{ { NumscriptPath: "example.num", SpecsPath: "example.num.specs.json", @@ -154,7 +155,7 @@ func TestParseErrSpecs(t *testing.T) { func TestSchemaErrSpecs(t *testing.T) { var out bytes.Buffer - success := runRawSpecs(&out, &out, []rawSpec{ + success := specs_format.RunSpecs(&out, &out, []specs_format.RawSpec{ { NumscriptPath: "example.num", SpecsPath: "example.num.specs.json", @@ -171,7 +172,7 @@ func TestSchemaErrSpecs(t *testing.T) { func TestNumscriptParseErr(t *testing.T) { var out bytes.Buffer - success := runRawSpecs(&out, &out, []rawSpec{ + success := specs_format.RunSpecs(&out, &out, []specs_format.RawSpec{ { NumscriptPath: "example.num", SpecsPath: "example.num.specs.json", @@ -199,7 +200,7 @@ func TestRuntimeErr(t *testing.T) { } ` - success := runRawSpecs(&out, &out, []rawSpec{ + success := specs_format.RunSpecs(&out, &out, []specs_format.RawSpec{ { NumscriptPath: "example.num", SpecsPath: "example.num.specs.json", From 1302479d9134baada3f84e348c4031f929a505d8 Mon Sep 17 00:00:00 2001 From: ascandone Date: Fri, 18 Jul 2025 11:30:29 +0200 Subject: [PATCH 55/59] feat: make it work recursively --- internal/cmd/test.go | 48 ++++++++++++++++++++++++++------------------ 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/internal/cmd/test.go b/internal/cmd/test.go index 412f67ba..77a1b5f6 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -1,11 +1,11 @@ package cmd import ( + "io/fs" "os" "path/filepath" "strings" - "github.com/formancehq/numscript/internal/ansi" "github.com/formancehq/numscript/internal/specs_format" "github.com/spf13/cobra" ) @@ -13,42 +13,50 @@ import ( func readSpecsFiles() []specs_format.RawSpec { var specs []specs_format.RawSpec - for _, path := range opts.paths { - path = strings.TrimSuffix(path, "/") + for _, root := range opts.paths { + root = strings.TrimSuffix(root, "/") - specsFilePaths, err := filepath.Glob(path + "/*.num.specs.json") - if err != nil { - panic(err) - } + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } - if len(specsFilePaths) == 0 { - _, _ = os.Stderr.Write([]byte(ansi.ColorRed("No specs files found\n"))) - os.Exit(1) - } + // Skip directories + if d.IsDir() { + return nil + } - for _, specsFilePath := range specsFilePaths { - numscriptFileName := strings.TrimSuffix(specsFilePath, ".specs.json") + if !strings.HasSuffix(path, ".num.specs.json") { + return nil + } + + numscriptFileName := strings.TrimSuffix(path, ".specs.json") - // TODO Improve err message ("no matching numscript for specsfile") numscriptContent, err := os.ReadFile(numscriptFileName) if err != nil { - _, _ = os.Stderr.Write([]byte(err.Error())) - os.Exit(1) + return err } - specsFileContent, err := os.ReadFile(specsFilePath) + specsFileContent, err := os.ReadFile(path) if err != nil { - _, _ = os.Stderr.Write([]byte(err.Error())) - os.Exit(1) + return err } specs = append(specs, specs_format.RawSpec{ NumscriptPath: numscriptFileName, - SpecsPath: specsFilePath, + SpecsPath: path, NumscriptContent: string(numscriptContent), SpecsFileContent: specsFileContent, }) + + return nil + }) + + if err != nil { + _, _ = os.Stderr.Write([]byte(err.Error())) + os.Exit(1) } + } return specs From 16d12a82a547bc5cc770ef70627f675ec0301df9 Mon Sep 17 00:00:00 2001 From: ascandone Date: Fri, 18 Jul 2025 11:41:57 +0200 Subject: [PATCH 56/59] feat: also allow single file --- internal/cmd/test.go | 77 +++++++++++++++++++++++++++++--------------- 1 file changed, 51 insertions(+), 26 deletions(-) diff --git a/internal/cmd/test.go b/internal/cmd/test.go index 77a1b5f6..162bdfe8 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -10,56 +10,75 @@ import ( "github.com/spf13/cobra" ) -func readSpecsFiles() []specs_format.RawSpec { +func readSpecFile(path string) (specs_format.RawSpec, error) { + numscriptFileName := strings.TrimSuffix(path, ".specs.json") + + numscriptContent, err := os.ReadFile(numscriptFileName) + if err != nil { + return specs_format.RawSpec{}, nil + } + + specsFileContent, err := os.ReadFile(path) + if err != nil { + return specs_format.RawSpec{}, err + } + + return specs_format.RawSpec{ + NumscriptPath: numscriptFileName, + SpecsPath: path, + NumscriptContent: string(numscriptContent), + SpecsFileContent: specsFileContent, + }, nil +} + +func readSpecsFiles() ([]specs_format.RawSpec, error) { var specs []specs_format.RawSpec for _, root := range opts.paths { root = strings.TrimSuffix(root, "/") - err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - - // Skip directories - if d.IsDir() { - return nil - } + info, err := os.Stat(root) + if err != nil { + _, _ = os.Stderr.Write([]byte(err.Error())) + os.Exit(1) + } - if !strings.HasSuffix(path, ".num.specs.json") { - return nil + if !info.IsDir() { + rawSpec, err := readSpecFile(root) + if err != nil { + return nil, err } - numscriptFileName := strings.TrimSuffix(path, ".specs.json") + specs = append(specs, rawSpec) + continue + } - numscriptContent, err := os.ReadFile(numscriptFileName) + err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } - specsFileContent, err := os.ReadFile(path) + // Skip directories + if d.IsDir() || !strings.HasSuffix(path, ".num.specs.json") { + return nil + } + + rawSpec, err := readSpecFile(path) if err != nil { return err } - specs = append(specs, specs_format.RawSpec{ - NumscriptPath: numscriptFileName, - SpecsPath: path, - NumscriptContent: string(numscriptContent), - SpecsFileContent: specsFileContent, - }) - + specs = append(specs, rawSpec) return nil }) if err != nil { - _, _ = os.Stderr.Write([]byte(err.Error())) - os.Exit(1) + return nil, err } } - return specs + return specs, nil } type testArgs struct { @@ -69,7 +88,13 @@ type testArgs struct { var opts = testArgs{} func runTestCmd() { - files := readSpecsFiles() + files, err := readSpecsFiles() + if err != nil { + _, _ = os.Stderr.Write([]byte(err.Error())) + os.Exit(1) + return + } + pass := specs_format.RunSpecs(os.Stdout, os.Stderr, files) if !pass { os.Exit(1) From 5829de1939c9d62e50f6b8f1ad4a15a6bd34fe42 Mon Sep 17 00:00:00 2001 From: ascandone Date: Fri, 18 Jul 2025 11:48:24 +0200 Subject: [PATCH 57/59] fix --- internal/cmd/test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/test.go b/internal/cmd/test.go index 162bdfe8..56624e1c 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -15,7 +15,7 @@ func readSpecFile(path string) (specs_format.RawSpec, error) { numscriptContent, err := os.ReadFile(numscriptFileName) if err != nil { - return specs_format.RawSpec{}, nil + return specs_format.RawSpec{}, err } specsFileContent, err := os.ReadFile(path) From 4eead8aa5c036a4f14a0ba601745b1af8c1cbd8f Mon Sep 17 00:00:00 2001 From: ascandone Date: Fri, 18 Jul 2025 11:49:19 +0200 Subject: [PATCH 58/59] refactor: moved function in different module --- internal/cmd/test.go | 76 +-------------------------------- internal/specs_format/runner.go | 74 ++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 75 deletions(-) diff --git a/internal/cmd/test.go b/internal/cmd/test.go index 56624e1c..92788bad 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -1,86 +1,12 @@ package cmd import ( - "io/fs" "os" - "path/filepath" - "strings" "github.com/formancehq/numscript/internal/specs_format" "github.com/spf13/cobra" ) -func readSpecFile(path string) (specs_format.RawSpec, error) { - numscriptFileName := strings.TrimSuffix(path, ".specs.json") - - numscriptContent, err := os.ReadFile(numscriptFileName) - if err != nil { - return specs_format.RawSpec{}, err - } - - specsFileContent, err := os.ReadFile(path) - if err != nil { - return specs_format.RawSpec{}, err - } - - return specs_format.RawSpec{ - NumscriptPath: numscriptFileName, - SpecsPath: path, - NumscriptContent: string(numscriptContent), - SpecsFileContent: specsFileContent, - }, nil -} - -func readSpecsFiles() ([]specs_format.RawSpec, error) { - var specs []specs_format.RawSpec - - for _, root := range opts.paths { - root = strings.TrimSuffix(root, "/") - - info, err := os.Stat(root) - if err != nil { - _, _ = os.Stderr.Write([]byte(err.Error())) - os.Exit(1) - } - - if !info.IsDir() { - rawSpec, err := readSpecFile(root) - if err != nil { - return nil, err - } - - specs = append(specs, rawSpec) - continue - } - - err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - - // Skip directories - if d.IsDir() || !strings.HasSuffix(path, ".num.specs.json") { - return nil - } - - rawSpec, err := readSpecFile(path) - if err != nil { - return err - } - - specs = append(specs, rawSpec) - return nil - }) - - if err != nil { - return nil, err - } - - } - - return specs, nil -} - type testArgs struct { paths []string } @@ -88,7 +14,7 @@ type testArgs struct { var opts = testArgs{} func runTestCmd() { - files, err := readSpecsFiles() + files, err := specs_format.ReadSpecsFiles(opts.paths) if err != nil { _, _ = os.Stderr.Write([]byte(err.Error())) os.Exit(1) diff --git a/internal/specs_format/runner.go b/internal/specs_format/runner.go index 72df25c8..d7537fbd 100644 --- a/internal/specs_format/runner.go +++ b/internal/specs_format/runner.go @@ -4,6 +4,9 @@ import ( "encoding/json" "fmt" "io" + "io/fs" + "os" + "path/filepath" "slices" "strings" @@ -27,6 +30,77 @@ type TestResult struct { Result TestCaseResult } +func readSpecFile(path string) (RawSpec, error) { + numscriptFileName := strings.TrimSuffix(path, ".specs.json") + + numscriptContent, err := os.ReadFile(numscriptFileName) + if err != nil { + return RawSpec{}, err + } + + specsFileContent, err := os.ReadFile(path) + if err != nil { + return RawSpec{}, err + } + + return RawSpec{ + NumscriptPath: numscriptFileName, + SpecsPath: path, + NumscriptContent: string(numscriptContent), + SpecsFileContent: specsFileContent, + }, nil +} + +func ReadSpecsFiles(paths []string) ([]RawSpec, error) { + var specs []RawSpec + + for _, root := range paths { + root = strings.TrimSuffix(root, "/") + + info, err := os.Stat(root) + if err != nil { + _, _ = os.Stderr.Write([]byte(err.Error())) + os.Exit(1) + } + + if !info.IsDir() { + rawSpec, err := readSpecFile(root) + if err != nil { + return nil, err + } + + specs = append(specs, rawSpec) + continue + } + + err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + // Skip directories + if d.IsDir() || !strings.HasSuffix(path, ".num.specs.json") { + return nil + } + + rawSpec, err := readSpecFile(path) + if err != nil { + return err + } + + specs = append(specs, rawSpec) + return nil + }) + + if err != nil { + return nil, err + } + + } + + return specs, nil +} + func RunSpecs(stdout io.Writer, stderr io.Writer, rawSpecs []RawSpec) bool { if len(rawSpecs) == 0 { _, _ = stderr.Write([]byte(ansi.ColorRed("No specs files found\n"))) From 6be6937e8f6139c95e02829e94fdc5a51ae8c590 Mon Sep 17 00:00:00 2001 From: ascandone Date: Fri, 18 Jul 2025 13:42:38 +0200 Subject: [PATCH 59/59] feat: renamed fields --- internal/specs_format/index.go | 16 ++++++++-------- internal/specs_format/parse_test.go | 2 +- internal/specs_format/run_test.go | 12 ++++++------ specs.schema.json | 12 ++++++------ 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/internal/specs_format/index.go b/internal/specs_format/index.go index 0d57e1a3..f4f27d6a 100644 --- a/internal/specs_format/index.go +++ b/internal/specs_format/index.go @@ -14,22 +14,22 @@ import ( type Specs struct { FeatureFlags []string `json:"featureFlags,omitempty"` Balances interpreter.Balances `json:"balances,omitempty"` - Vars interpreter.VariablesMap `json:"vars,omitempty"` - Meta interpreter.AccountsMetadata `json:"accountsMeta,omitempty"` + Vars interpreter.VariablesMap `json:"variables,omitempty"` + Meta interpreter.AccountsMetadata `json:"metadata,omitempty"` TestCases []TestCase `json:"testCases,omitempty"` } type TestCase struct { It string `json:"it"` Balances interpreter.Balances `json:"balances,omitempty"` - Vars interpreter.VariablesMap `json:"vars,omitempty"` - Meta interpreter.AccountsMetadata `json:"accountsMeta,omitempty"` + Vars interpreter.VariablesMap `json:"variables,omitempty"` + Meta interpreter.AccountsMetadata `json:"metadata,omitempty"` // Expectations ExpectMissingFunds bool `json:"expect.missingFunds,omitempty"` ExpectPostings []interpreter.Posting `json:"expect.postings,omitempty"` - ExpectTxMeta map[string]string `json:"expect.txMeta,omitempty"` - ExpectAccountsMeta interpreter.AccountsMetadata `json:"expect.accountsMeta,omitempty"` + ExpectTxMeta map[string]string `json:"expect.txMetadata,omitempty"` + ExpectAccountsMeta interpreter.AccountsMetadata `json:"expect.metadata,omitempty"` ExpectVolumes interpreter.Balances `json:"expect.volumes,omitempty"` ExpectMovements Movements `json:"expect.movements,omitempty"` } @@ -38,8 +38,8 @@ type TestCaseResult struct { It string `json:"it"` Pass bool `json:"pass"` Balances interpreter.Balances `json:"balances"` - Vars interpreter.VariablesMap `json:"vars"` - Meta interpreter.AccountsMetadata `json:"accountsMeta"` + Vars interpreter.VariablesMap `json:"variables"` + Meta interpreter.AccountsMetadata `json:"metadata"` // Assertions FailedAssertions []AssertionMismatch[any] `json:"failedAssertions"` diff --git a/internal/specs_format/parse_test.go b/internal/specs_format/parse_test.go index e5b990bd..71a91f6f 100644 --- a/internal/specs_format/parse_test.go +++ b/internal/specs_format/parse_test.go @@ -17,7 +17,7 @@ func TestParseSpecs(t *testing.T) { "balances": { "alice": { "EUR": 200 } }, - "vars": { + "variables": { "amt": "200" }, "testCases": [ diff --git a/internal/specs_format/run_test.go b/internal/specs_format/run_test.go index 4a4e060b..16582ca0 100644 --- a/internal/specs_format/run_test.go +++ b/internal/specs_format/run_test.go @@ -28,7 +28,7 @@ func TestRunSpecsSimple(t *testing.T) { "testCases": [ { "it": "t1", - "vars": { "source": "src", "amount": "42" }, + "variables": { "source": "src", "amount": "42" }, "balances": { "src": { "USD": 9999 } }, "expect.postings": [ { "source": "src", "destination": "dest", "asset": "USD", "amount": 42 } @@ -87,11 +87,11 @@ func TestRunSpecsSimple(t *testing.T) { func TestRunSpecsMergeOuter(t *testing.T) { j := `{ - "vars": { "source": "src", "amount": "42" }, + "variables": { "source": "src", "amount": "42" }, "balances": { "src": { "USD": 10 } }, "testCases": [ { - "vars": { "amount": "1" }, + "variables": { "amount": "1" }, "balances": { "src": { "EUR": 2 }, "dest": { "USD": 1 } @@ -161,7 +161,7 @@ func TestRunWithMissingBalance(t *testing.T) { "testCases": [ { "it": "t1", - "vars": { "source": "src", "amount": "42" }, + "variables": { "source": "src", "amount": "42" }, "balances": { "src": { "USD": 1 } }, "expect.missingFunds": false, "expect.postings": null @@ -214,7 +214,7 @@ func TestRunWithMissingBalanceWhenExpectedPostings(t *testing.T) { "testCases": [ { "it": "t1", - "vars": { "source": "src", "amount": "42" }, + "variables": { "source": "src", "amount": "42" }, "balances": { "src": { "USD": 1 } }, "expect.postings": [ { "source": "src", "destination": "dest", "asset": "USD", "amount": 1 } @@ -268,7 +268,7 @@ func TestNullPostingsIsNoop(t *testing.T) { "testCases": [ { "it": "t1", - "vars": { "source": "src", "amount": "42" }, + "variables": { "source": "src", "amount": "42" }, "balances": { "src": { "USD": 1 } }, "expect.postings": null } diff --git a/specs.schema.json b/specs.schema.json index 2b65a9a0..79a2aaf8 100644 --- a/specs.schema.json +++ b/specs.schema.json @@ -9,10 +9,10 @@ "balances": { "$ref": "#/definitions/Balances" }, - "vars": { + "variables": { "$ref": "#/definitions/VariablesMap" }, - "accountsMeta": { + "metadata": { "$ref": "#/definitions/AccountsMetadata" }, "testCases": { @@ -37,10 +37,10 @@ "balances": { "$ref": "#/definitions/Balances" }, - "vars": { + "variables": { "$ref": "#/definitions/VariablesMap" }, - "accountsMeta": { + "metadata": { "$ref": "#/definitions/AccountsMetadata" }, "expect.postings": { @@ -56,11 +56,11 @@ "$ref": "#/definitions/Movements" }, - "expect.txMeta": { + "expect.txMetadata": { "$ref": "#/definitions/TxMetadata" }, - "expect.accountsMeta": { + "expect.metadata": { "$ref": "#/definitions/AccountsMetadata" },