From c10419616c49162c3ff533e61cb4fcf136f3e4c5 Mon Sep 17 00:00:00 2001 From: Johnny Miller <163300+millerjp@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:03:21 +0200 Subject: [PATCH] test: add godog BDD suite and fuzz targets (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the BDD contract layer and two fuzz targets: * tests/bdd/bdd_test.go — godog entry with Strict: true, build tag bdd, mirrors axonops/mask verbatim. * tests/bdd/steps/steps.go — step defs; per-scenario World carried through context.WithValue, fresh instance per scenario via sc.Before. Type assertion in worldFrom is guarded with a comma-ok panic message per the project's no-unguarded-assertions rule. * tests/bdd/features/syncmap.feature — one Feature with 15 Rule blocks and 37 scenarios covering every public symbol plus a concurrent Store scenario. No @wip/@skip/@pending tags. * syncmap_fuzz_test.go — FuzzLoadStore (round-trip invariant) and FuzzConcurrent (4 goroutines over the fuzz bytes; race- clean, no ordering assertions). * .github/workflows/ci.yml — bdd-strict-mode-guard job ported verbatim from mask. * go.mod/go.sum — github.com/cucumber/godog v0.15.1. All 37 BDD scenarios pass under -race -tags bdd. Fuzz smoke green over 1s at 75k+ and 22k+ executions respectively. Coverage remains at 100%. --- .github/workflows/ci.yml | 51 ++++ go.mod | 8 + go.sum | 47 +++- syncmap_fuzz_test.go | 111 +++++++++ tests/bdd/bdd_test.go | 54 +++++ tests/bdd/features/syncmap.feature | 310 ++++++++++++++++++++++++ tests/bdd/steps/steps.go | 374 +++++++++++++++++++++++++++++ 7 files changed, 951 insertions(+), 4 deletions(-) create mode 100644 syncmap_fuzz_test.go create mode 100644 tests/bdd/bdd_test.go create mode 100644 tests/bdd/features/syncmap.feature create mode 100644 tests/bdd/steps/steps.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10b8cf5..8e7664c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,6 +152,57 @@ jobs: exit "$failed" + bdd-strict-mode-guard: + name: BDD strict mode guard + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.2 + - name: Verify every godog TestSuite sets Strict=true + run: | + set -euo pipefail + + # Locate godog entry files: any .go file that constructs a + # godog.TestSuite{}. We require each one to carry an explicit + # `Strict: true` option. Silently running with strict mode off + # lets unimplemented steps slip through — the single most common + # BDD failure mode and something the project has been burned by. + mapfile -t files < <(grep -rln "godog.TestSuite" --include='*.go' tests || true) + + if [ "${#files[@]}" -eq 0 ]; then + echo "::error::No godog.TestSuite entry found — is the BDD suite still present?" + exit 1 + fi + + failed=0 + for f in "${files[@]}"; do + if grep -qE "Strict:\s*false" "$f"; then + echo "::error file=$f::BDD strict mode is disabled (Strict: false). Strict mode is mandatory." + failed=1 + continue + fi + if ! grep -qE "Strict:\s*true" "$f"; then + echo "::error file=$f::BDD entry file does not set Strict: true explicitly. The flag MUST be present — do not rely on defaults." + failed=1 + continue + fi + echo "$f: OK (Strict: true)" + done + + # Forbid any env-var or build-tag escape hatch that would disable + # strict mode conditionally. + if grep -rnE "Strict:\s*[A-Za-z_][A-Za-z0-9_]*" --include='*.go' tests | grep -vE "Strict:\s*true" ; then + echo "::error::A BDD entry file appears to gate Strict on a variable — strict mode MUST be a compile-time constant true." + failed=1 + fi + + # Flag suspicious skip/pending patterns in feature files. + if grep -rnE "@(skip|wip|pending|todo)\b" --include='*.feature' tests ; then + echo "::error::Feature files contain @skip / @wip / @pending / @todo tags. These are incompatible with strict mode and must be removed before merge." + failed=1 + fi + + exit "$failed" + attribution-guard: name: No AI attribution runs-on: ubuntu-latest diff --git a/go.mod b/go.mod index 2ddf56c..dc226a5 100644 --- a/go.mod +++ b/go.mod @@ -3,13 +3,21 @@ module github.com/axonops/syncmap go 1.26 require ( + github.com/cucumber/godog v0.15.1 github.com/stretchr/testify v1.11.1 go.uber.org/goleak v1.3.0 ) require ( + github.com/cucumber/gherkin/go/v26 v26.2.0 // indirect + github.com/cucumber/messages/go/v21 v21.0.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/gofrs/uuid v4.3.1+incompatible // indirect + github.com/hashicorp/go-immutable-radix v1.3.1 // indirect + github.com/hashicorp/go-memdb v1.3.4 // indirect + github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/kr/text v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/pflag v1.0.7 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 7d7c525..529e60c 100644 --- a/go.sum +++ b/go.sum @@ -1,18 +1,57 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI= +github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0= +github.com/cucumber/godog v0.15.1 h1:rb/6oHDdvVZKS66hrhpjFQFHjthFSrQBCOI1LwshNTI= +github.com/cucumber/godog v0.15.1/go.mod h1:qju+SQDewOljHuq9NSM66s0xEhogx0q30flfxL4WUk8= +github.com/cucumber/messages/go/v21 v21.0.1 h1:wzA0LxwjlWQYZd32VTlAVDTkW6inOFmSM+RuOwHZiMI= +github.com/cucumber/messages/go/v21 v21.0.1/go.mod h1:zheH/2HS9JLVFukdrsPWoPdmUtmYQAQPLk7w5vWsk5s= +github.com/cucumber/messages/go/v22 v22.0.0/go.mod h1:aZipXTKc0JnjCsXrJnuZpWhtay93k7Rn3Dee7iyPJjs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gofrs/uuid v4.3.1+incompatible h1:0/KbAdpx3UXAx1kEOWHJeOkpbgRFGHVgv+CFIY7dBJI= +github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/hashicorp/go-immutable-radix v1.3.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-memdb v1.3.4 h1:XSL3NR682X/cVk2IeV0d70N4DZ9ljI885xAEU8IoK3c= +github.com/hashicorp/go-memdb v1.3.4/go.mod h1:uBTr1oQbtuMgd1SSGoR8YV27eT3sBHbYiNm53bMpgSg= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= +github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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/syncmap_fuzz_test.go b/syncmap_fuzz_test.go new file mode 100644 index 0000000..389b124 --- /dev/null +++ b/syncmap_fuzz_test.go @@ -0,0 +1,111 @@ +// Copyright 2026 AxonOps Limited. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package syncmap_test + +import ( + "strconv" + "strings" + "sync" + "testing" + + "github.com/axonops/syncmap" +) + +// FuzzLoadStore verifies that a value stored under a key is always retrievable +// with the correct value and found==true. The fuzz engine varies both the key +// and the value. +func FuzzLoadStore(f *testing.F) { + // Seed corpus + f.Add("", 0) + f.Add("k", 1) + f.Add("\x00key", -1) + f.Add("ü", 2147483647) + f.Add("a very long key "+strings.Repeat("x", 128), -42) + + f.Fuzz(func(t *testing.T, k string, v int) { + var m syncmap.SyncMap[string, int] + m.Store(k, v) + got, ok := m.Load(k) + if !ok { + t.Fatalf("Load(%q): expected found=true after Store, got false", k) + } + if got != v { + t.Errorf("Load(%q): expected %d, got %d", k, v, got) + } + }) +} + +// FuzzConcurrent exercises concurrent Load, Store, Delete, and LoadOrStore +// operations driven by arbitrary byte sequences. It must not panic and must +// be clean under -race. +// +// No ordering assertions are made; the test fails only on panic or a data race +// detected by the race detector. +func FuzzConcurrent(f *testing.F) { + // Seed corpus + f.Add([]byte("")) + f.Add([]byte("\x00")) + f.Add([]byte("\x01\x02\x03\x04")) + f.Add([]byte("\xff\xfe\xfd\x00\x01\x02\x03")) + + f.Fuzz(func(t *testing.T, data []byte) { + if len(data) == 0 { + return + } + + var m syncmap.SyncMap[string, int] + + // Distribute the byte slice across 4 goroutines. Each goroutine processes + // its own quarter of the data so the workload genuinely exercises + // concurrent access without ordering assumptions. + const numGoroutines = 4 + chunkSize := (len(data) + numGoroutines - 1) / numGoroutines + + var wg sync.WaitGroup + for g := 0; g < numGoroutines; g++ { + start := g * chunkSize + if start >= len(data) { + break + } + end := start + chunkSize + if end > len(data) { + end = len(data) + } + chunk := data[start:end] + + wg.Add(1) + go func(chunk []byte) { + defer wg.Done() + for _, b := range chunk { + op := b % 4 + key := strconv.Itoa(int(b) % 8) + value := int(b) + switch op { + case 0: + m.Load(key) + case 1: + m.Store(key, value) + case 2: + m.Delete(key) + case 3: + m.LoadOrStore(key, value) + } + } + }(chunk) + } + + wg.Wait() + }) +} diff --git a/tests/bdd/bdd_test.go b/tests/bdd/bdd_test.go new file mode 100644 index 0000000..ffb6573 --- /dev/null +++ b/tests/bdd/bdd_test.go @@ -0,0 +1,54 @@ +// Copyright 2026 AxonOps Limited. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build bdd + +package bdd_test + +import ( + "os" + "testing" + + "github.com/cucumber/godog" + "github.com/cucumber/godog/colors" + + "github.com/axonops/syncmap/tests/bdd/steps" +) + +// TestGodog is the single entry point for the godog BDD suite. It runs every +// .feature file under tests/bdd/features. +// +// Strict mode is MANDATORY and MUST NOT be disabled. When Strict is true, +// godog fails the suite on any undefined or pending step — silently +// skipping unimplemented fixtures is the single most common BDD failure +// mode and we refuse to let it past CI. The CI workflow carries a guard +// job that greps every BDD entry file for the Strict flag and fails the +// build if the flag is missing or set to false. See +// .github/workflows/ci.yml → bdd-strict-mode-guard. +func TestGodog(t *testing.T) { + suite := godog.TestSuite{ + ScenarioInitializer: steps.Register, + Options: &godog.Options{ + Format: "pretty", + Paths: []string{"features"}, + Output: colors.Colored(os.Stdout), + Randomize: -1, + Strict: true, + TestingT: t, + }, + } + if got := suite.Run(); got != 0 { + t.Fatalf("godog suite exited with %d", got) + } +} diff --git a/tests/bdd/features/syncmap.feature b/tests/bdd/features/syncmap.feature new file mode 100644 index 0000000..c29b84c --- /dev/null +++ b/tests/bdd/features/syncmap.feature @@ -0,0 +1,310 @@ +Feature: SyncMap public API + + Rule: Load + + Scenario: Present key returns value and found true + Given an empty SyncMap of string to int + And the key "hello" has been stored with value 42 + When I Load key "hello" + Then the returned value is 42 + And the found flag is true + + Scenario: Missing key returns zero value and found false + Given an empty SyncMap of string to int + When I Load key "missing" + Then the returned value is the zero value + And the found flag is false + + Scenario: Zero value stored is distinguished from missing + Given an empty SyncMap of string to int + And the key "z" has been stored with value 0 + When I Load key "z" + Then the returned value is 0 + And the found flag is true + + Rule: Store + + Scenario: Overwrite replaces value + Given an empty SyncMap of string to int + And the key "k" has been stored with value 1 + When I Store key "k" with value 2 + And I Load key "k" + Then the returned value is 2 + And the found flag is true + + Scenario: Empty string key is valid + Given an empty SyncMap of string to int + When I Store key "" with value 99 + And I Load key "" + Then the returned value is 99 + And the found flag is true + + Rule: LoadOrStore + + Scenario: Absent key stores value and returns it with loaded false + Given an empty SyncMap of string to int + When I LoadOrStore key "new" with value 10 + Then the returned value is 10 + And the loaded flag is false + And the map contains key "new" with value 10 + + Scenario: Present key loads existing value with loaded true + Given an empty SyncMap of string to int + And the key "existing" has been stored with value 55 + When I LoadOrStore key "existing" with value 0 + Then the returned value is 55 + And the loaded flag is true + + Scenario: Zero value stored is loadable by LoadOrStore + Given an empty SyncMap of string to int + And the key "z" has been stored with value 0 + When I LoadOrStore key "z" with value 7 + Then the returned value is 0 + And the loaded flag is true + + Rule: LoadAndDelete + + Scenario: Present key returns value and removes entry + Given an empty SyncMap of string to int + And the key "target" has been stored with value 77 + When I LoadAndDelete key "target" + Then the returned value is 77 + And the found flag is true + And the map does not contain key "target" + + Scenario: Missing key returns zero value and loaded false + Given an empty SyncMap of string to int + When I LoadAndDelete key "absent" + Then the returned value is the zero value + And the found flag is false + + Scenario: Zero value stored is distinguished from missing by loaded flag + Given an empty SyncMap of string to int + And the key "z" has been stored with value 0 + When I LoadAndDelete key "z" + Then the returned value is 0 + And the found flag is true + And the map does not contain key "z" + + Rule: Delete + + Scenario: Existing key is removed + Given an empty SyncMap of string to int + And the key "del" has been stored with value 5 + When I Delete key "del" + Then the map does not contain key "del" + + Scenario: Deleting a missing key is a no-op + Given an empty SyncMap of string to int + When I Delete key "never-existed" + Then no panic occurs + And the map does not contain key "never-existed" + + Scenario: Double delete is a no-op + Given an empty SyncMap of string to int + And the key "d" has been stored with value 1 + When I Delete key "d" + And I Delete key "d" + Then no panic occurs + And the map does not contain key "d" + + Rule: Swap + + Scenario: Absent key stores value and returns zero value with loaded false + Given an empty SyncMap of string to int + When I Swap key "new" with value 88 + Then the returned value is the zero value + And the found flag is false + And the map contains key "new" with value 88 + + Scenario: Present key returns old value and overwrites with loaded true + Given an empty SyncMap of string to int + And the key "s" has been stored with value 10 + When I Swap key "s" with value 20 + Then the returned value is 10 + And the found flag is true + And the map contains key "s" with value 20 + + Scenario: Zero V is distinguished from absent by loaded flag + Given an empty SyncMap of string to int + And the key "z" has been stored with value 0 + When I Swap key "z" with value 1 + Then the returned value is 0 + And the found flag is true + + Rule: Clear + + Scenario: Clear on an empty map is a no-op + Given an empty SyncMap of string to int + When I Clear the map + Then no panic occurs + And Len equals 0 + + Scenario: Clear on a populated map leaves it empty + Given an empty SyncMap of string to int + And the map contains the following entries + | key | value | + | a | 1 | + | b | 2 | + | c | 3 | + When I Clear the map + Then Len equals 0 + And the map does not contain key "a" + And the map does not contain key "b" + And the map does not contain key "c" + + Rule: Range + + Scenario: Range visits every entry + Given an empty SyncMap of string to int + And the map contains the following entries + | key | value | + | x | 10 | + | y | 20 | + | z | 30 | + When I Range all entries + Then Range visited exactly 3 entries + + Scenario: Early return stops iteration + Given an empty SyncMap of string to int + And the map contains the following entries + | key | value | + | a | 1 | + | b | 2 | + | c | 3 | + | d | 4 | + | e | 5 | + When I Range and stop after 2 entries + Then Range visited exactly 2 entries + + Scenario: Empty map invokes callback zero times + Given an empty SyncMap of string to int + When I Range all entries + Then Range visited exactly 0 entries + + Rule: Len + + Scenario: Empty map has Len of zero + Given an empty SyncMap of string to int + When I request Len + Then Len returns 0 + + Scenario: Len equals number of stored entries + Given an empty SyncMap of string to int + And the map contains the following entries + | key | value | + | p | 1 | + | q | 2 | + | r | 3 | + When I request Len + Then Len returns 3 + + Rule: Map + + Scenario: Snapshot matches stored entries + Given an empty SyncMap of string to int + And the map contains the following entries + | key | value | + | m | 100 | + | n | 200 | + When I request Map + Then the snapshot length equals 2 + And the snapshot contains key "m" with value 100 + And the snapshot contains key "n" with value 200 + + Rule: Keys + + Scenario: Keys matches stored keys + Given an empty SyncMap of string to int + And the map contains the following entries + | key | value | + | alpha | 1 | + | beta | 2 | + | gamma | 3 | + When I request Keys + Then the captured keys length equals 3 + And the captured keys contain "alpha" + And the captured keys contain "beta" + And the captured keys contain "gamma" + + Scenario: Empty map returns empty keys slice + Given an empty SyncMap of string to int + When I request Keys + Then the captured keys length equals 0 + + Rule: Values + + Scenario: Values matches stored values + Given an empty SyncMap of string to int + And the map contains the following entries + | key | value | + | a | 11 | + | b | 22 | + | c | 33 | + When I request Values + Then the captured values length equals 3 + And the captured values contain 11 + And the captured values contain 22 + And the captured values contain 33 + + Scenario: Empty map returns empty values slice + Given an empty SyncMap of string to int + When I request Values + Then the captured values length equals 0 + + Rule: CompareAndSwap + + Scenario: Matching old value swaps and returns true + Given an empty SyncMap of string to int + And the key "k" has been stored with value 10 + When I CompareAndSwap key "k" from 10 to 20 + Then the swapped flag is true + And the map contains key "k" with value 20 + + Scenario: Mismatched old value does not swap and returns false + Given an empty SyncMap of string to int + And the key "k" has been stored with value 10 + When I CompareAndSwap key "k" from 99 to 20 + Then the swapped flag is false + And the map contains key "k" with value 10 + + Scenario: Missing key returns false and does not store + Given an empty SyncMap of string to int + When I CompareAndSwap key "absent" from 0 to 1 + Then the swapped flag is false + And the map does not contain key "absent" + + Scenario: Zero V match swaps successfully + Given an empty SyncMap of string to int + And the key "z" has been stored with value 0 + When I CompareAndSwap key "z" from 0 to 1 + Then the swapped flag is true + And the map contains key "z" with value 1 + + Rule: CompareAndDelete + + Scenario: Matching value deletes entry and returns true + Given an empty SyncMap of string to int + And the key "k" has been stored with value 10 + When I CompareAndDelete key "k" expecting 10 + Then the deleted flag is true + And the map does not contain key "k" + + Scenario: Mismatched value does not delete and returns false + Given an empty SyncMap of string to int + And the key "k" has been stored with value 10 + When I CompareAndDelete key "k" expecting 99 + Then the deleted flag is false + And the map contains key "k" with value 10 + + Scenario: Missing key returns false + Given an empty SyncMap of string to int + When I CompareAndDelete key "absent" expecting 0 + Then the deleted flag is false + + Rule: Concurrency + + Scenario: Multiple goroutines storing disjoint keys yield correct total count + Given an empty SyncMap of string to int + When 4 goroutines each Store 25 keys + Then Len equals 100 diff --git a/tests/bdd/steps/steps.go b/tests/bdd/steps/steps.go new file mode 100644 index 0000000..ffc0876 --- /dev/null +++ b/tests/bdd/steps/steps.go @@ -0,0 +1,374 @@ +// Copyright 2026 AxonOps Limited. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build bdd + +package steps + +import ( + "context" + "fmt" + "strconv" + "sync" + + "github.com/cucumber/godog" + + "github.com/axonops/syncmap" +) + +// worldKey is the context key used to store per-scenario World state. +type worldKey struct{} + +// World holds per-scenario state. A fresh World is created before each scenario. +type World struct { + m *syncmap.SyncMap[string, int] + prev int + found bool + ok bool // loaded flag from LoadOrStore, or generic bool + swapped bool // CompareAndSwap + deleted bool // CompareAndDelete + values []int // from Values() / captured Range + keys []string // from Keys() / captured Range + snap map[string]int // from Map() + length int // from Len() + rangeHits []string // ordered sequence of keys visited by Range +} + +func newWorld() *World { + return &World{ + m: &syncmap.SyncMap[string, int]{}, + } +} + +func worldFrom(ctx context.Context) *World { + w, ok := ctx.Value(worldKey{}).(*World) + if !ok { + panic("bdd: World missing from context — Before hook did not run") + } + return w +} + +// Register wires all step definitions into the given scenario context. +func Register(sc *godog.ScenarioContext) { + sc.Before(func(ctx context.Context, _ *godog.Scenario) (context.Context, error) { + return context.WithValue(ctx, worldKey{}, newWorld()), nil + }) + + // ── Given ────────────────────────────────────────────────────────────── + + sc.Step(`^an empty SyncMap of string to int$`, func(ctx context.Context) error { + worldFrom(ctx).m = &syncmap.SyncMap[string, int]{} + return nil + }) + + sc.Step(`^the map contains the following entries$`, func(ctx context.Context, table *godog.Table) error { + w := worldFrom(ctx) + for _, row := range table.Rows[1:] { // skip header row + key := row.Cells[0].Value + val, err := strconv.Atoi(row.Cells[1].Value) + if err != nil { + return fmt.Errorf("invalid value %q in table: %w", row.Cells[1].Value, err) + } + w.m.Store(key, val) + } + return nil + }) + + sc.Step(`^the key "([^"]*)" has been stored with value (-?\d+)$`, func(ctx context.Context, key string, value int) error { + worldFrom(ctx).m.Store(key, value) + return nil + }) + + // ── When ─────────────────────────────────────────────────────────────── + + sc.Step(`^I Store key "([^"]*)" with value (-?\d+)$`, func(ctx context.Context, key string, value int) error { + worldFrom(ctx).m.Store(key, value) + return nil + }) + + sc.Step(`^I Load key "([^"]*)"$`, func(ctx context.Context, key string) error { + w := worldFrom(ctx) + w.prev, w.found = w.m.Load(key) + return nil + }) + + sc.Step(`^I LoadOrStore key "([^"]*)" with value (-?\d+)$`, func(ctx context.Context, key string, value int) error { + w := worldFrom(ctx) + w.prev, w.ok = w.m.LoadOrStore(key, value) + return nil + }) + + sc.Step(`^I LoadAndDelete key "([^"]*)"$`, func(ctx context.Context, key string) error { + w := worldFrom(ctx) + w.prev, w.found = w.m.LoadAndDelete(key) + return nil + }) + + sc.Step(`^I Delete key "([^"]*)"$`, func(ctx context.Context, key string) error { + worldFrom(ctx).m.Delete(key) + return nil + }) + + sc.Step(`^I Swap key "([^"]*)" with value (-?\d+)$`, func(ctx context.Context, key string, value int) error { + w := worldFrom(ctx) + w.prev, w.found = w.m.Swap(key, value) + return nil + }) + + sc.Step(`^I Clear the map$`, func(ctx context.Context) error { + worldFrom(ctx).m.Clear() + return nil + }) + + sc.Step(`^I Range all entries$`, func(ctx context.Context) error { + w := worldFrom(ctx) + w.rangeHits = nil + w.m.Range(func(key string, _ int) bool { + w.rangeHits = append(w.rangeHits, key) + return true + }) + return nil + }) + + sc.Step(`^I Range and stop after (\d+) entries$`, func(ctx context.Context, n int) error { + w := worldFrom(ctx) + w.rangeHits = nil + count := 0 + w.m.Range(func(key string, _ int) bool { + w.rangeHits = append(w.rangeHits, key) + count++ + return count < n + }) + return nil + }) + + sc.Step(`^I request Len$`, func(ctx context.Context) error { + w := worldFrom(ctx) + w.length = w.m.Len() + return nil + }) + + sc.Step(`^I request Map$`, func(ctx context.Context) error { + w := worldFrom(ctx) + w.snap = w.m.Map() + return nil + }) + + sc.Step(`^I request Keys$`, func(ctx context.Context) error { + w := worldFrom(ctx) + w.keys = w.m.Keys() + return nil + }) + + sc.Step(`^I request Values$`, func(ctx context.Context) error { + w := worldFrom(ctx) + w.values = w.m.Values() + return nil + }) + + sc.Step(`^I CompareAndSwap key "([^"]*)" from (-?\d+) to (-?\d+)$`, func(ctx context.Context, key string, from, to int) error { + w := worldFrom(ctx) + w.swapped = syncmap.CompareAndSwap(w.m, key, from, to) + return nil + }) + + sc.Step(`^I CompareAndDelete key "([^"]*)" expecting (-?\d+)$`, func(ctx context.Context, key string, old int) error { + w := worldFrom(ctx) + w.deleted = syncmap.CompareAndDelete(w.m, key, old) + return nil + }) + + sc.Step(`^(\d+) goroutines each Store (\d+) keys$`, func(ctx context.Context, goroutines, keysEach int) error { + w := worldFrom(ctx) + var wg sync.WaitGroup + for g := 0; g < goroutines; g++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for k := 0; k < keysEach; k++ { + key := fmt.Sprintf("g%d-k%d", id, k) + w.m.Store(key, id*keysEach+k) + } + }(g) + } + wg.Wait() + return nil + }) + + // ── Then ─────────────────────────────────────────────────────────────── + + sc.Step(`^the returned value is (-?\d+)$`, func(ctx context.Context, expected int) error { + w := worldFrom(ctx) + if w.prev != expected { + return fmt.Errorf("expected returned value %d, got %d", expected, w.prev) + } + return nil + }) + + sc.Step(`^the returned value is the zero value$`, func(ctx context.Context) error { + w := worldFrom(ctx) + if w.prev != 0 { + return fmt.Errorf("expected zero value, got %d", w.prev) + } + return nil + }) + + sc.Step(`^the found flag is (true|false)$`, func(ctx context.Context, flag string) error { + w := worldFrom(ctx) + expected := flag == "true" + if w.found != expected { + return fmt.Errorf("expected found=%v, got %v", expected, w.found) + } + return nil + }) + + sc.Step(`^the loaded flag is (true|false)$`, func(ctx context.Context, flag string) error { + w := worldFrom(ctx) + expected := flag == "true" + if w.ok != expected { + return fmt.Errorf("expected loaded=%v, got %v", expected, w.ok) + } + return nil + }) + + sc.Step(`^the swapped flag is (true|false)$`, func(ctx context.Context, flag string) error { + w := worldFrom(ctx) + expected := flag == "true" + if w.swapped != expected { + return fmt.Errorf("expected swapped=%v, got %v", expected, w.swapped) + } + return nil + }) + + sc.Step(`^the deleted flag is (true|false)$`, func(ctx context.Context, flag string) error { + w := worldFrom(ctx) + expected := flag == "true" + if w.deleted != expected { + return fmt.Errorf("expected deleted=%v, got %v", expected, w.deleted) + } + return nil + }) + + sc.Step(`^Len returns (\d+)$`, func(ctx context.Context, expected int) error { + w := worldFrom(ctx) + if w.length != expected { + return fmt.Errorf("expected Len %d, got %d", expected, w.length) + } + return nil + }) + + sc.Step(`^Len equals (\d+)$`, func(ctx context.Context, expected int) error { + w := worldFrom(ctx) + got := w.m.Len() + if got != expected { + return fmt.Errorf("expected Len %d, got %d", expected, got) + } + return nil + }) + + sc.Step(`^the captured values contain (-?\d+)$`, func(ctx context.Context, expected int) error { + w := worldFrom(ctx) + for _, v := range w.values { + if v == expected { + return nil + } + } + return fmt.Errorf("captured values %v do not contain %d", w.values, expected) + }) + + sc.Step(`^the captured values length equals (\d+)$`, func(ctx context.Context, expected int) error { + w := worldFrom(ctx) + got := len(w.values) + if got != expected { + return fmt.Errorf("expected captured values length %d, got %d", expected, got) + } + return nil + }) + + sc.Step(`^the captured keys contain "([^"]*)"$`, func(ctx context.Context, expected string) error { + w := worldFrom(ctx) + for _, k := range w.keys { + if k == expected { + return nil + } + } + return fmt.Errorf("captured keys %v do not contain %q", w.keys, expected) + }) + + sc.Step(`^the captured keys length equals (\d+)$`, func(ctx context.Context, expected int) error { + w := worldFrom(ctx) + got := len(w.keys) + if got != expected { + return fmt.Errorf("expected captured keys length %d, got %d", expected, got) + } + return nil + }) + + sc.Step(`^the snapshot length equals (\d+)$`, func(ctx context.Context, expected int) error { + w := worldFrom(ctx) + got := len(w.snap) + if got != expected { + return fmt.Errorf("expected snapshot length %d, got %d", expected, got) + } + return nil + }) + + sc.Step(`^the snapshot contains key "([^"]*)" with value (-?\d+)$`, func(ctx context.Context, key string, expected int) error { + w := worldFrom(ctx) + got, ok := w.snap[key] + if !ok { + return fmt.Errorf("snapshot does not contain key %q", key) + } + if got != expected { + return fmt.Errorf("snapshot[%q] = %d, want %d", key, got, expected) + } + return nil + }) + + sc.Step(`^Range visited exactly (\d+) entries$`, func(ctx context.Context, expected int) error { + w := worldFrom(ctx) + got := len(w.rangeHits) + if got != expected { + return fmt.Errorf("expected Range to visit %d entries, visited %d", expected, got) + } + return nil + }) + + sc.Step(`^the map does not contain key "([^"]*)"$`, func(ctx context.Context, key string) error { + w := worldFrom(ctx) + _, ok := w.m.Load(key) + if ok { + return fmt.Errorf("map unexpectedly contains key %q", key) + } + return nil + }) + + sc.Step(`^the map contains key "([^"]*)" with value (-?\d+)$`, func(ctx context.Context, key string, expected int) error { + w := worldFrom(ctx) + got, ok := w.m.Load(key) + if !ok { + return fmt.Errorf("map does not contain key %q", key) + } + if got != expected { + return fmt.Errorf("map[%q] = %d, want %d", key, got, expected) + } + return nil + }) + + sc.Step(`^no panic occurs$`, func(_ context.Context) error { + // No-op: Go tests panic automatically on a real panic. + // This step documents the contract that no panic should occur. + return nil + }) +}