From 55717a81326033662090f805ef546d4ac6742d6e Mon Sep 17 00:00:00 2001 From: Johnny Miller <163300+millerjp@users.noreply.github.com> Date: Mon, 20 Apr 2026 18:55:59 +0200 Subject: [PATCH 1/2] test: rewrite unit tests in external package with testify/goleak (#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the internal-package smoke test with a comprehensive black-box suite. Every public method has a dedicated TestX with subtests covering happy path, missing/zero-value distinctions, and edge cases. Four concurrency tests exercise store-read contention, LoadOrStore idempotency, Range-during-writes, and Delete-during-Range — each stable over -count=10 runs under -race. Coverage rises from 36.1% to 100.0% of statements in syncmap.go. Test deps added to go.mod: testify v1.11.1 and goleak v1.3.0. goleak wired via a single TestMain hook (VerifyTestMain) — per-test VerifyNone calls were dropped because t.Parallel() siblings would detect each other's goroutines. The CI coverage gate is left at 35% in this commit and raised to 95% in a follow-up commit on the same branch so the threshold flip is bisectable. --- go.mod | 12 + go.sum | 18 ++ syncmap_smoke_test.go | 122 ---------- syncmap_test.go | 550 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 580 insertions(+), 122 deletions(-) create mode 100644 go.sum delete mode 100644 syncmap_smoke_test.go create mode 100644 syncmap_test.go diff --git a/go.mod b/go.mod index 08c93fa..2ddf56c 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,15 @@ module github.com/axonops/syncmap go 1.26 + +require ( + github.com/stretchr/testify v1.11.1 + go.uber.org/goleak v1.3.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7d7c525 --- /dev/null +++ b/go.sum @@ -0,0 +1,18 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +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/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/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/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_smoke_test.go b/syncmap_smoke_test.go deleted file mode 100644 index c4b9a12..0000000 --- a/syncmap_smoke_test.go +++ /dev/null @@ -1,122 +0,0 @@ -// 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 - -import ( - "fmt" - "math/rand" - "strconv" - "sync" - "testing" - "time" -) - -// Some basic smoke tests, not very comprehensive - -func TestSyncMapGeneric(t *testing.T) { - sm := &SyncMap[string, string]{} - var wg sync.WaitGroup - // Run 11 goroutines in parallel each adding 10 items - for i := 0; i <= 100; i += 10 { - wg.Add(1) - go func(offset int) { - for v := offset; v < offset+10; v++ { - sm.Store(fmt.Sprintf("key %d", v), fmt.Sprintf("value %d", v)) - } - wg.Done() - }(i) - } - wg.Wait() - - // Display and verify the contents of the map - seenNums := make(map[int]struct{}) - realLen := 0 - sm.Range(func(k string, v string) bool { - fmt.Printf("%s : %s\n", k, v) - n, _ := strconv.Atoi(k[4:]) - if n < 0 || n >= 110 { - t.Errorf("Unexpected number in map: %d", n) - } - seenNums[n] = struct{}{} - realLen++ - return true - }) - - expectedLen := 110 - smLen := sm.Len() - if smLen != expectedLen { - t.Errorf("Len() does not match expected length. Expected=%d, Actual=%d", expectedLen, smLen) - } - if realLen != expectedLen { - t.Errorf("Counted length does not match expected length. Expected=%d, Actual=%d", expectedLen, realLen) - } - for i := 0; i < 110; i++ { - _, ok := seenNums[i] - if !ok { - t.Errorf("Number %d not found in the map", i) - } - } - - fmt.Printf("Total values: %d\n", sm.Len()) -} - -// Concurrent read and write test - -func TestSyncMapReadWrite(t *testing.T) { - sm := &SyncMap[string, string]{} - var wg sync.WaitGroup - // Writers - for i := 0; i <= 100; i += 10 { - wg.Add(1) - go func(offset int) { - defer wg.Done() - for k := 0; k < 10; k++ { - v := k + offset - ks := fmt.Sprintf("key %d", k) - vs := fmt.Sprintf("value %d", v) - sm.Store(ks, vs) - //fmt.Printf("writer %d set %s = %s\n", offset, ks, vs) - time.Sleep(time.Millisecond) - } - }(i) - } - - // Readers - for i := 0; i < 5; i++ { - wg.Add(1) - go func(id int) { - defer wg.Done() - for n := 0; n < 10; n++ { - //l := sm.Len() - k := fmt.Sprintf("key %d", rand.Intn(10)) - v, ok := sm.Load(k) - if ok { - fmt.Printf("Reader %d: %s = %s\n", id, k, v) - } else { - fmt.Printf("Reader %d: %s not set\n", id, k) - } - time.Sleep(time.Millisecond) - } - }(i) - } - - wg.Wait() - fmt.Println() - sm.Range(func(k string, v string) bool { - fmt.Printf("%s : %s\n", k, v) - return true - }) - fmt.Printf("Total values: %d\n", sm.Len()) -} diff --git a/syncmap_test.go b/syncmap_test.go new file mode 100644 index 0000000..73f042a --- /dev/null +++ b/syncmap_test.go @@ -0,0 +1,550 @@ +// 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 ( + "sort" + "sync" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" + + "github.com/axonops/syncmap" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} + +func TestLoad(t *testing.T) { + t.Parallel() + + t.Run("present", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + m.Store("k", 42) + v, ok := m.Load("k") + assert.True(t, ok) + assert.Equal(t, 42, v) + }) + + t.Run("missing", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + v, ok := m.Load("absent") + assert.False(t, ok) + assert.Zero(t, v) + }) + + t.Run("zero_value_stored", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + m.Store("z", 0) + v, ok := m.Load("z") + assert.True(t, ok) + assert.Equal(t, 0, v) + }) + + t.Run("empty_key", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, string]{} + m.Store("", "empty") + v, ok := m.Load("") + assert.True(t, ok) + assert.Equal(t, "empty", v) + }) + + t.Run("zero_map", func(t *testing.T) { + t.Parallel() + var m syncmap.SyncMap[string, int] + v, ok := m.Load("anything") + assert.False(t, ok) + assert.Zero(t, v) + }) +} + +func TestStore(t *testing.T) { + t.Parallel() + + t.Run("new_key", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + m.Store("a", 1) + v, ok := m.Load("a") + assert.True(t, ok) + assert.Equal(t, 1, v) + }) + + t.Run("overwrite", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + m.Store("a", 1) + m.Store("a", 2) + v, ok := m.Load("a") + assert.True(t, ok) + assert.Equal(t, 2, v) + }) + + t.Run("empty_key", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, string]{} + m.Store("", "val") + v, ok := m.Load("") + assert.True(t, ok) + assert.Equal(t, "val", v) + }) + + t.Run("pointer_value", func(t *testing.T) { + t.Parallel() + type payload struct{ n int } + m := &syncmap.SyncMap[string, *payload]{} + p := &payload{n: 7} + m.Store("ptr", p) + got, ok := m.Load("ptr") + assert.True(t, ok) + assert.Same(t, p, got) + }) +} + +func TestLoadOrStore(t *testing.T) { + t.Parallel() + + t.Run("absent_stores", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + actual, loaded := m.LoadOrStore("k", 10) + assert.False(t, loaded) + assert.Equal(t, 10, actual) + v, ok := m.Load("k") + require.True(t, ok) + assert.Equal(t, 10, v) + }) + + t.Run("present_loads", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + m.Store("k", 99) + actual, loaded := m.LoadOrStore("k", 0) + assert.True(t, loaded) + assert.Equal(t, 99, actual) + }) + + t.Run("zero_value_loadable", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + m.Store("k", 0) + actual, loaded := m.LoadOrStore("k", 1) + assert.True(t, loaded) + assert.Equal(t, 0, actual) + }) +} + +func TestLoadAndDelete(t *testing.T) { + t.Parallel() + + t.Run("present", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + m.Store("k", 55) + v, loaded := m.LoadAndDelete("k") + assert.True(t, loaded) + assert.Equal(t, 55, v) + _, ok := m.Load("k") + assert.False(t, ok) + }) + + t.Run("missing", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + v, loaded := m.LoadAndDelete("absent") + assert.False(t, loaded) + assert.Zero(t, v) + }) + + t.Run("zero_value_distinguished_from_missing", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + m.Store("k", 0) + v, loaded := m.LoadAndDelete("k") + assert.True(t, loaded) + assert.Equal(t, 0, v) + _, stillPresent := m.Load("k") + assert.False(t, stillPresent) + }) +} + +func TestDelete(t *testing.T) { + t.Parallel() + + t.Run("existing", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + m.Store("k", 1) + m.Delete("k") + _, ok := m.Load("k") + assert.False(t, ok) + }) + + t.Run("missing_noop", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + assert.NotPanics(t, func() { m.Delete("never-existed") }) + }) + + t.Run("double_delete", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + m.Store("k", 1) + m.Delete("k") + assert.NotPanics(t, func() { m.Delete("k") }) + _, ok := m.Load("k") + assert.False(t, ok) + }) +} + +func TestRange(t *testing.T) { + t.Parallel() + + t.Run("all_visited", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[int, int]{} + for i := 0; i < 5; i++ { + m.Store(i, i*10) + } + seen := make(map[int]int) + m.Range(func(k, v int) bool { + seen[k] = v + return true + }) + assert.Equal(t, 5, len(seen)) + for i := 0; i < 5; i++ { + assert.Equal(t, i*10, seen[i]) + } + }) + + t.Run("early_return_stops", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[int, int]{} + for i := 0; i < 10; i++ { + m.Store(i, i) + } + count := 0 + m.Range(func(k, v int) bool { + count++ + return count < 3 + }) + assert.Equal(t, 3, count) + }) + + t.Run("empty_map_no_calls", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, string]{} + calls := 0 + m.Range(func(k, v string) bool { + calls++ + return true + }) + assert.Equal(t, 0, calls) + }) +} + +func TestLen(t *testing.T) { + t.Parallel() + + t.Run("empty", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + assert.Equal(t, 0, m.Len()) + }) + + t.Run("after_store", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + m.Store("a", 1) + m.Store("b", 2) + m.Store("c", 3) + assert.Equal(t, 3, m.Len()) + }) + + t.Run("after_delete", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + m.Store("a", 1) + m.Store("b", 2) + m.Delete("a") + assert.Equal(t, 1, m.Len()) + }) +} + +func TestMap(t *testing.T) { + t.Parallel() + + t.Run("snapshot_independence", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + m.Store("x", 1) + snap := m.Map() + require.NotNil(t, snap) + snap["x"] = 999 + snap["y"] = 42 + v, ok := m.Load("x") + assert.True(t, ok) + assert.Equal(t, 1, v) + _, ok = m.Load("y") + assert.False(t, ok) + }) + + t.Run("empty_returns_non_nil_empty", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + snap := m.Map() + assert.NotNil(t, snap) + assert.Empty(t, snap) + }) + + t.Run("matches_contents", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + m.Store("a", 1) + m.Store("b", 2) + snap := m.Map() + assert.Equal(t, map[string]int{"a": 1, "b": 2}, snap) + }) +} + +func TestKeys(t *testing.T) { + t.Parallel() + + t.Run("empty_is_empty", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + keys := m.Keys() + assert.Empty(t, keys) + }) + + t.Run("matches", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + m.Store("a", 1) + m.Store("b", 2) + m.Store("c", 3) + keys := m.Keys() + sort.Strings(keys) + assert.Equal(t, []string{"a", "b", "c"}, keys) + }) + + t.Run("length_equals_len", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + m.Store("x", 10) + m.Store("y", 20) + assert.Equal(t, m.Len(), len(m.Keys())) + }) +} + +func TestItems(t *testing.T) { + t.Parallel() + + t.Run("empty_is_empty", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + items := m.Items() + assert.Empty(t, items) + }) + + t.Run("matches", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + m.Store("a", 1) + m.Store("b", 2) + m.Store("c", 3) + items := m.Items() + sort.Ints(items) + assert.Equal(t, []int{1, 2, 3}, items) + }) + + t.Run("length_equals_len", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + m.Store("x", 10) + m.Store("y", 20) + assert.Equal(t, m.Len(), len(m.Items())) + }) +} + +func TestZeroValueUsable(t *testing.T) { + t.Parallel() + var sm syncmap.SyncMap[string, int] + sm.Store("k", 77) + v, ok := sm.Load("k") + assert.True(t, ok) + assert.Equal(t, 77, v) +} + +func TestConcurrentWritersReaders(t *testing.T) { + t.Parallel() + + const writers = 16 + const readers = 8 + const opsEach = 1000 + + m := &syncmap.SyncMap[int, int]{} + + // Track which keys each writer committed so we can verify the final state. + // Each writer i owns keys in the range [i*opsEach, (i+1)*opsEach). + var wg sync.WaitGroup + + for w := 0; w < writers; w++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + base := id * opsEach + for j := 0; j < opsEach; j++ { + m.Store(base+j, id) + } + }(w) + } + + for r := 0; r < readers; r++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := 0; j < opsEach; j++ { + key := (id*opsEach + j) % (writers * opsEach) + if v, ok := m.Load(key); ok { + assert.GreaterOrEqual(t, v, 0) + assert.Less(t, v, writers) + } + } + }(r) + } + + wg.Wait() + + assert.Equal(t, writers*opsEach, m.Len()) +} + +func TestLoadOrStoreContention(t *testing.T) { + t.Parallel() + + const goroutines = 100 + + m := &syncmap.SyncMap[string, int]{} + var stored atomic.Int32 + var wg sync.WaitGroup + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, loaded := m.LoadOrStore("contended", 1) + if !loaded { + stored.Add(1) + } + }() + } + + wg.Wait() + + assert.Equal(t, int32(1), stored.Load(), "exactly one goroutine should win the store") +} + +func TestRangeDuringWrites(t *testing.T) { + t.Parallel() + + const keyCount = 100 + const writerCount = 8 + + m := &syncmap.SyncMap[int, int]{} + for i := 0; i < keyCount; i++ { + m.Store(i, i) + } + + var wg sync.WaitGroup + + // Writers continuously overwrite keys across the whole key space while + // Range runs. Each writer cycles through all keys so the concurrent + // workload actually contends with the ranger. + var stop atomic.Int32 + for w := 0; w < writerCount; w++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for stop.Load() == 0 { + for j := 0; j < keyCount; j++ { + m.Store(j, id) + } + } + }(w) + } + + // A single Range pass; per sync.Map contract it may observe any subset + // of keys that were present at or after the Range started. + seen := make(map[int]struct{}) + m.Range(func(k, v int) bool { + seen[k] = struct{}{} + return true + }) + + stop.Store(1) + wg.Wait() + + // All observed keys must be within the valid key space [0, keyCount). + for k := range seen { + assert.GreaterOrEqual(t, k, 0) + assert.Less(t, k, keyCount) + } +} + +func TestDeleteDuringRange(t *testing.T) { + t.Parallel() + + const keyCount = 50 + + m := &syncmap.SyncMap[int, int]{} + for i := 0; i < keyCount; i++ { + m.Store(i, i) + } + + var wg sync.WaitGroup + + // Deleter removes all keys concurrently with the Range below. + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < keyCount; i++ { + m.Delete(i) + } + }() + + // Range must not panic regardless of concurrent deletes. Per sync.Map + // docs, a key deleted during Range may appear zero or one times — both + // outcomes are correct; we assert only on absence of panic and value + // validity. + assert.NotPanics(t, func() { + m.Range(func(k, v int) bool { + // Value must be in the valid range if observed. + assert.GreaterOrEqual(t, k, 0) + assert.Less(t, k, keyCount) + assert.Equal(t, k, v) + return true + }) + }) + + wg.Wait() +} From 5743721e4d59c45f40d35abd94c29b6838e31fb1 Mon Sep 17 00:00:00 2001 From: Johnny Miller <163300+millerjp@users.noreply.github.com> Date: Mon, 20 Apr 2026 18:57:14 +0200 Subject: [PATCH 2/2] ci: raise coverage threshold to 95% (#10) The preceding commit lifted coverage of syncmap.go to 100%. Flip the CI gate in ci.yml from the interim 35% floor to the AxonOps 95% target, and remove the TODO(#11) comment block. The release workflow at release.yml already enforced 95% independently. --- .github/workflows/ci.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 855ae26..42a609f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -259,10 +259,7 @@ jobs: if: matrix.os == 'ubuntu-latest' run: | total=$(go tool cover -func=coverage.out | awk '/^total:/ {print $3}' | tr -d '%') - # TODO(#11): raise threshold to 95.0 once the test rewrite lands. - # Today the fork carries only the existing smoke test (~36%). - # The gate exists to prevent regressions below current coverage. - threshold=35.0 + threshold=95.0 echo "Total coverage: ${total}% (threshold ${threshold}%)" awk -v t="$total" -v th="$threshold" 'BEGIN { if (t+0 < th+0) { exit 1 } }' \ || { echo "::error::coverage ${total}% < threshold ${threshold}%"; exit 1; }