diff --git a/.gitignore b/.gitignore index f03c235a..a87420c6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ dist/ coverage.* + +.direnv 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/ansi/ansi.go b/internal/ansi/ansi.go index 77eb617b..81227cbe 100644 --- a/internal/ansi/ansi.go +++ b/internal/ansi/ansi.go @@ -1,18 +1,48 @@ package ansi -import "fmt" +import ( + "fmt" + "strings" +) 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 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 { return col(s, 31) } +func ColorWhite(s string) string { + return col(s, 37) +} + +func ColorGreen(s string) string { + return col(s, 32) +} + func ColorYellow(s string) string { return col(s, 33) } @@ -20,3 +50,46 @@ func ColorYellow(s string) string { func ColorCyan(s string) string { return col(s, 36) } + +func ColorLight(s string) string { + return col(s, 97) // Bright white → light +} + +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) +} + +func ColorBrightYellow(s string) string { + return col(s, 93) +} + +// 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/root.go b/internal/cmd/root.go index b19ff0f1..557ad454 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(getTestCmd()) rootCmd.AddCommand(getRunCmd()) if err := rootCmd.Execute(); err != nil { diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 1c7434da..db8e5c47 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" @@ -21,7 +20,7 @@ const ( OutputFormatJson = "json" ) -type Args struct { +type runArgs struct { VariablesOpt string BalancesOpt string MetaOpt string @@ -38,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 } @@ -50,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 } @@ -67,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 { @@ -108,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), @@ -172,26 +171,19 @@ func showJson(result *interpreter.ExecutionResult) error { } 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() + 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 { - opts := Args{} + opts := runArgs{} cmd := cobra.Command{ Use: "run", diff --git a/internal/cmd/test.go b/internal/cmd/test.go new file mode 100644 index 00000000..92788bad --- /dev/null +++ b/internal/cmd/test.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "os" + + "github.com/formancehq/numscript/internal/specs_format" + "github.com/spf13/cobra" +) + +type testArgs struct { + paths []string +} + +var opts = testArgs{} + +func runTestCmd() { + files, err := specs_format.ReadSpecsFiles(opts.paths) + 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) + } +} + +func getTestCmd() *cobra.Command { + + cmd := &cobra.Command{ + 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 { + paths = []string{"."} + } + + opts.paths = paths + runTestCmd() + }, + } + + return cmd +} 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/accounts_metadata.go b/internal/interpreter/accounts_metadata.go new file mode 100644 index 00000000..a0cec91a --- /dev/null +++ b/internal/interpreter/accounts_metadata.go @@ -0,0 +1,50 @@ +package interpreter + +import ( + "github.com/formancehq/numscript/internal/utils" +) + +func (m AccountsMetadata) fetchAccountMetadata(account string) AccountMetadata { + return utils.MapGetOrPutDefault(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) + utils.MapGetOrPutDefault(clonedAccountBalances, asset, func() string { + return metadataValue + }) + } + } + return cloned +} + +func (m AccountsMetadata) Merge(update AccountsMetadata) { + for acc, accBalances := range update { + cachedAcc := utils.MapGetOrPutDefault(m, acc, func() AccountMetadata { + return AccountMetadata{} + }) + + for curr, amt := range accBalances { + cachedAcc[curr] = amt + } + } +} + +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 89c1ba81..222eb865 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 { +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{} }) @@ -78,10 +69,10 @@ 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 { + cachedAcc := utils.MapGetOrPutDefault(b, acc, func() AccountBalance { return AccountBalance{} }) @@ -90,3 +81,22 @@ func (b Balances) mergeBalance(update Balances) { } } } + +func (b Balances) PrettyPrint() string { + header := []string{"Account", "Asset", "Balance"} + + var rows [][]string + for account, accBalances := range b { + for asset, balance := range accBalances { + row := []string{account, asset, balance.String()} + rows = append(rows, row) + } + } + 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 5fe642a8..f41e24c9 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" ) @@ -41,9 +42,40 @@ func TestCloneBalances(t *testing.T) { }, } - cloned := fullBalance.deepClone() + cloned := fullBalance.DeepClone() fullBalance["alice"]["USD/2"].Set(big.NewInt(42)) 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()) +} + +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/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/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 f3670a18..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 @@ -599,7 +602,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 @@ -989,3 +992,21 @@ 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) +} + +func PrettyPrintMeta(meta Metadata) string { + m := map[string]string{} + for k, v := range meta { + m[k] = v.String() + } + + return utils.CsvPrettyMap("Name", "Value", m) +} 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/parser/parser.go b/internal/parser/parser.go index aec04672..e997af82 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 { @@ -79,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/specs_format/__snapshots__/runner_test.snap b/internal/specs_format/__snapshots__/runner_test.snap new file mode 100755 index 00000000..0cfee566 --- /dev/null +++ b/internal/specs_format/__snapshots__/runner_test.snap @@ -0,0 +1,138 @@ + +[TestShowDiff - 1] + { + "common": "ok", +- "x": 42 ++ "x": 100 + } + +--- + +[TestSingleTest - 1] +❯ example.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) + +--- + +[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) + +--- + +[TestNoFilesErr - 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/specs_format/index.go b/internal/specs_format/index.go new file mode 100644 index 00000000..f4f27d6a --- /dev/null +++ b/internal/specs_format/index.go @@ -0,0 +1,257 @@ +package specs_format + +import ( + "context" + "math/big" + "reflect" + + "github.com/formancehq/numscript/internal/interpreter" + "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/utils" +) + +// --- Specs: +type Specs struct { + FeatureFlags []string `json:"featureFlags,omitempty"` + Balances interpreter.Balances `json:"balances,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:"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.txMetadata,omitempty"` + ExpectAccountsMeta interpreter.AccountsMetadata `json:"expect.metadata,omitempty"` + ExpectVolumes interpreter.Balances `json:"expect.volumes,omitempty"` + ExpectMovements Movements `json:"expect.movements,omitempty"` +} + +type TestCaseResult struct { + It string `json:"it"` + Pass bool `json:"pass"` + Balances interpreter.Balances `json:"balances"` + Vars interpreter.VariablesMap `json:"variables"` + Meta interpreter.AccountsMetadata `json:"metadata"` + + // Assertions + FailedAssertions []AssertionMismatch[any] `json:"failedAssertions"` +} + +type SpecsResult struct { + // Invariants: total==passing+failing + Total uint `json:"total"` + Passing uint `json:"passing"` + Failing uint `json:"failing"` + Cases []TestCaseResult +} + +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 { + return append(failedAssertions, AssertionMismatch[any]{ + Assertion: assertion, + Expected: expected, + Got: got, + }) + } + + return failedAssertions +} + +func Check(program parser.Program, specs Specs) (SpecsResult, interpreter.InterpreterError) { + specsResult := SpecsResult{} + + for _, testCase := range specs.TestCases { + meta := mergeAccountsMeta(specs.Meta, testCase.Meta) + balances := mergeBalances(specs.Balances, testCase.Balances) + vars := mergeVars(specs.Vars, testCase.Vars) + + specsResult.Total += 1 + + featureFlags := make(map[string]struct{}) + for _, flag := range specs.FeatureFlags { + featureFlags[flag] = struct{}{} + } + + result, err := interpreter.RunProgram( + context.Background(), + program, + vars, + interpreter.StaticStore{ + Meta: meta, + Balances: balances, + }, + featureFlags, + ) + + var failedAssertions []AssertionMismatch[any] + + if err != nil { + _, 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 { + + if testCase.ExpectMissingFunds { + failedAssertions = append(failedAssertions, AssertionMismatch[any]{ + Assertion: "expect.missingFunds", + Expected: true, + Got: false, + }) + } + + if testCase.ExpectPostings != nil { + failedAssertions = runAssertion[any](failedAssertions, + "expect.postings", + testCase.ExpectPostings, + result.Postings, + reflect.DeepEqual, + ) + } + + 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.ExpectTxMeta, + metadata, + reflect.DeepEqual, + ) + } + + if testCase.ExpectAccountsMeta != nil { + failedAssertions = runAssertion[any](failedAssertions, + "expect.accountsMeta", + testCase.ExpectAccountsMeta, + result.AccountsMetadata, + reflect.DeepEqual, + ) + } + + if testCase.ExpectVolumes != nil { + failedAssertions = runAssertion(failedAssertions, + "expect.volumes", + testCase.ExpectVolumes, + getVolumes(result.Postings, balances), + interpreter.CompareBalances, + ) + } + + if testCase.ExpectMovements != nil { + failedAssertions = runAssertion[any](failedAssertions, + "expect.movements", + testCase.ExpectMovements, + getMovements(result.Postings), + reflect.DeepEqual, + ) + } + + } + + pass := len(failedAssertions) == 0 + 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, + FailedAssertions: failedAssertions, + }) + } + + return specsResult, nil +} + +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 +} + +type AssertionMismatch[T any] struct { + Assertion string `json:"assertion"` + 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 +} diff --git a/internal/specs_format/parse_test.go b/internal/specs_format/parse_test.go new file mode 100644 index 00000000..71a91f6f --- /dev/null +++ b/internal/specs_format/parse_test.go @@ -0,0 +1,76 @@ +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" +) + +func TestParseSpecs(t *testing.T) { + + raw := ` +{ + "balances": { + "alice": { "EUR": 200 } + }, + "variables": { + "amt": "200" + }, + "testCases": [ + { + "it": "d1", + "balances": { + "bob": { "EUR": 42 } + }, + "expect.postings": [ + { + "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{ + Balances: interpreter.Balances{ + "alice": { + "EUR": big.NewInt(200), + }, + }, + Vars: interpreter.VariablesMap{ + "amt": "200", + }, + TestCases: []specs_format.TestCase{ + { + It: "d1", + Balances: interpreter.Balances{ + "bob": { + "EUR": big.NewInt(42), + }, + }, + ExpectPostings: []interpreter.Posting{ + { + Source: "src", + Destination: "dest", + Asset: "EUR", + Amount: big.NewInt(100), + }, + }, + }, + }, + }, specs) + +} diff --git a/internal/specs_format/run_test.go b/internal/specs_format/run_test.go new file mode 100644 index 00000000..16582ca0 --- /dev/null +++ b/internal/specs_format/run_test.go @@ -0,0 +1,308 @@ +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", + "variables": { "source": "src", "amount": "42" }, + "balances": { "src": { "USD": 9999 } }, + "expect.postings": [ + { "source": "src", "destination": "dest", "asset": "USD", "amount": 42 } + ] + } + ] + }` + + var specs specs_format.Specs + err := json.Unmarshal([]byte(j), &specs) + require.Nil(t, err) + + out, err := specs_format.Check(exampleProgram.Value, specs) + require.Nil(t, err) + + 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{}, + 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) + +} + +func TestRunSpecsMergeOuter(t *testing.T) { + j := `{ + "variables": { "source": "src", "amount": "42" }, + "balances": { "src": { "USD": 10 } }, + "testCases": [ + { + "variables": { "amount": "1" }, + "balances": { + "src": { "EUR": 2 }, + "dest": { "USD": 1 } + }, + "it": "t1", + "expect.postings": [ + { "source": "src", "destination": "dest", "asset": "USD", "amount": 1 } + ] + } + ] + }` + + var specs specs_format.Specs + err := json.Unmarshal([]byte(j), &specs) + require.Nil(t, err) + + out, err := specs_format.Check(exampleProgram.Value, specs) + require.Nil(t, err) + + 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), + }, + }, + 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) + +} + +func TestRunWithMissingBalance(t *testing.T) { + j := `{ + "testCases": [ + { + "it": "t1", + "variables": { "source": "src", "amount": "42" }, + "balances": { "src": { "USD": 1 } }, + "expect.missingFunds": false, + "expect.postings": null + } + ] + }` + + var specs specs_format.Specs + err := json.Unmarshal([]byte(j), &specs) + require.Nil(t, err) + + out, err := specs_format.Check(exampleProgram.Value, specs) + require.Nil(t, err) + + 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{}, + FailedAssertions: []specs_format.AssertionMismatch[any]{ + { + Assertion: "expect.missingFunds", + Expected: false, + Got: true, + }, + }, + // ExpectedPostings: nil, + // ActualPostings: nil, + }, + }, + }, out) + +} + +func TestRunWithMissingBalanceWhenExpectedPostings(t *testing.T) { + j := `{ + "testCases": [ + { + "it": "t1", + "variables": { "source": "src", "amount": "42" }, + "balances": { "src": { "USD": 1 } }, + "expect.postings": [ + { "source": "src", "destination": "dest", "asset": "USD", "amount": 1 } + ] + } + ] + }` + + var specs specs_format.Specs + err := json.Unmarshal([]byte(j), &specs) + require.Nil(t, err) + + out, err := specs_format.Check(exampleProgram.Value, specs) + require.Nil(t, err) + + 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{}, + FailedAssertions: []specs_format.AssertionMismatch[any]{ + { + Assertion: "expect.missingFunds", + Got: true, + Expected: false, + }, + }, + }, + }, + }, out) + +} + +func TestNullPostingsIsNoop(t *testing.T) { + exampleProgram := parser.Parse(``) + + j := `{ + "testCases": [ + { + "it": "t1", + "variables": { "source": "src", "amount": "42" }, + "balances": { "src": { "USD": 1 } }, + "expect.postings": null + } + ] + }` + + var specs specs_format.Specs + err := json.Unmarshal([]byte(j), &specs) + require.Nil(t, err) + + out, err := specs_format.Check(exampleProgram.Value, specs) + require.Nil(t, err) + + 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{}, + FailedAssertions: nil, + }, + }, + }, out) + +} diff --git a/internal/specs_format/runner.go b/internal/specs_format/runner.go new file mode 100644 index 00000000..d7537fbd --- /dev/null +++ b/internal/specs_format/runner.go @@ -0,0 +1,345 @@ +package specs_format + +import ( + "encoding/json" + "fmt" + "io" + "io/fs" + "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/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 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"))) + 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/specs_format/runner_test.go b/internal/specs_format/runner_test.go new file mode 100644 index 00000000..21973bb0 --- /dev/null +++ b/internal/specs_format/runner_test.go @@ -0,0 +1,213 @@ +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 + specs_format.ShowDiff( + &buf, + 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 := specs_format.RunSpecs(&out, &out, []specs_format.RawSpec{ + { + 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 := specs_format.RunSpecs(&out, &out, []specs_format.RawSpec{ + { + NumscriptPath: "example.num", + SpecsPath: "example.num.specs.json", + NumscriptContent: script, + SpecsFileContent: []byte(specs), + }, + }) + + require.False(t, success) + + snaps.MatchSnapshot(t, out.String()) +} + +func TestNoFilesErr(t *testing.T) { + var out bytes.Buffer + success := specs_format.RunSpecs(&out, &out, []specs_format.RawSpec{}) + require.False(t, success) + snaps.MatchSnapshot(t, out.String()) +} + +func TestParseErrSpecs(t *testing.T) { + var out bytes.Buffer + + success := specs_format.RunSpecs(&out, &out, []specs_format.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 := specs_format.RunSpecs(&out, &out, []specs_format.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 := specs_format.RunSpecs(&out, &out, []specs_format.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 := specs_format.RunSpecs(&out, &out, []specs_format.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()) +} diff --git a/internal/utils/__snapshots__/pretty_csv_test.snap b/internal/utils/__snapshots__/pretty_csv_test.snap new file mode 100755 index 00000000..aea68443 --- /dev/null +++ b/internal/utils/__snapshots__/pretty_csv_test.snap @@ -0,0 +1,14 @@ + +[TestPrettyCsv - 1] +| Account | Asset | Balance | +| alice | EUR/2 | 1 | +| 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 new file mode 100644 index 00000000..aef8337e --- /dev/null +++ b/internal/utils/pretty_csv.go @@ -0,0 +1,83 @@ +package utils + +import ( + "fmt" + "slices" + "strings" + + "github.com/formancehq/numscript/internal/ansi" +) + +// Fails if the header is shorter than any of the rows +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 = 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") +} + +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 new file mode 100644 index 00000000..010456b7 --- /dev/null +++ b/internal/utils/pretty_csv_test.go @@ -0,0 +1,30 @@ +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"}, + }, 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) +} diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 8a7bc7b4..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 { @@ -56,3 +46,42 @@ func Filter[T any](slice []T, predicate func(x T) bool) []T { } 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) +} + +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) + }) +} diff --git a/specs.schema.json b/specs.schema.json new file mode 100644 index 00000000..79a2aaf8 --- /dev/null +++ b/specs.schema.json @@ -0,0 +1,155 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Specs", + "type": "object", + "additionalProperties": false, + "required": ["testCases"], + "properties": { + "$schema": { "type": "string" }, + "balances": { + "$ref": "#/definitions/Balances" + }, + "variables": { + "$ref": "#/definitions/VariablesMap" + }, + "metadata": { + "$ref": "#/definitions/AccountsMetadata" + }, + "testCases": { + "type": "array", + "items": { "$ref": "#/definitions/TestCase" } + }, + "featureFlags": { + "type": "array", + "items": { "type": "string" } + } + }, + "definitions": { + "TestCase": { + "type": "object", + "required": ["it"], + "additionalProperties": false, + "properties": { + "it": { + "type": "string", + "description": "Test case description" + }, + "balances": { + "$ref": "#/definitions/Balances" + }, + "variables": { + "$ref": "#/definitions/VariablesMap" + }, + "metadata": { + "$ref": "#/definitions/AccountsMetadata" + }, + "expect.postings": { + "type": "array", + "items": { "$ref": "#/definitions/Posting" } + }, + + "expect.volumes": { + "$ref": "#/definitions/Balances" + }, + + "expect.movements": { + "$ref": "#/definitions/Movements" + }, + + "expect.txMetadata": { + "$ref": "#/definitions/TxMetadata" + }, + + "expect.metadata": { + "$ref": "#/definitions/AccountsMetadata" + }, + + "expect.missingFunds": { + "type": "boolean" + } + } + }, + + "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]+)?)$": { + "type": "number" + } + } + } + } + }, + + "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]+)?)$": { + "type": "number" + } + } + } + } + } + } + }, + + "Posting": { + "type": "object", + "properties": { + "source": { "type": "string" }, + "destination": { "type": "string" }, + "asset": { + "type": "string", + "pattern": "^([A-Z]+(/[0-9]+)?)$" + }, + "amount": { + "type": "number" + } + }, + "required": ["source", "destination", "asset", "amount"] + } + } +}