Compare commits

..
24 Commits
Author SHA1 Message Date
Lee Sang GyuandDuDaoDong d561d4da77 fix(package): [promise] recover a panic after the promise settled (#420)
* fix(package): [promise] recover a panic after the promise settled

* test(package): [promise] cover a panic after the promise settled

---------

Co-authored-by: DuDaoDong <dudaodong@gmail.com>
2026-11-09 19:51:25 +08:00
Lee Sang Gyu ed237c18b3 fix(package): [strutil] handle non-ASCII letters in case conversion (#413)
* fix(package): [strutil] handle non-ASCII letters in case conversion

* test(package): [strutil] cover non-ASCII letters in case conversion
2026-11-09 17:38:17 +08:00
Lee Sang Gyu 4c9c628a31 fix(package): [mathutil] compute Combination without factorial overflow (#414)
* fix(package): [mathutil] compute Combination without factorial overflow

* test(package): [mathutil] cover Combination for large n
2026-11-09 17:37:55 +08:00
Lee Sang Gyu 5839ab33a9 fix(package): [datetime] use local zone for zero hour timestamps (#415)
* fix(package): [datetime] use local zone for zero hour timestamps

* test(package): [datetime] cover zero hour timestamps in other zones
2026-11-09 17:37:13 +08:00
Lee Sang Gyu b09d6af4a4 fix(package): [concurrency] hold the map lock while taking the try lock (#417)
* fix(package): [concurrency] hold the map lock while taking the try lock

* test(package): [concurrency] check TryKeyedLocker mutual exclusion
2026-11-09 17:36:58 +08:00
Lee Sang Gyu d859b91c57 fix(package): [eventbus] let listeners change subscriptions in Publish (#416)
* fix(package): [eventbus] let listeners change subscriptions in Publish

* test(package): [eventbus] cover reentrant and concurrent subscribe
2026-11-09 17:17:31 +08:00
Lee Sang Gyu 6eb12896e3 fix(package): [maputil] keep ToMarkdownTable column order stable (#418)
* fix(package): [maputil] keep ToMarkdownTable column order stable

* test(package): [maputil] cover ToMarkdownTable default column order
2026-11-09 17:11:21 +08:00
Lee Sang Gyu 0112b99cd9 fix(package): [formatter] only group leading digits in Comma (#419)
* fix(package): [formatter] only group leading digits in Comma

* test(package): [formatter] cover Comma with exponent and suffixes
2026-11-09 12:34:27 +08:00
Lee Sang Gyu 961a76b6d4 fix(package): [promise] keep every error when Any rejects (#421)
* fix(package): [promise] keep every error when Any rejects

* test(package): [promise] cover errors kept by Any
2026-11-09 12:31:40 +08:00
Lee Sang Gyu c403dd3a2a fix(package): [concurrency] stop Take when the source closes (#424)
* fix(package): [concurrency] stop Take when the source closes

* test(package): [concurrency] cover Take with closed and idle source
2026-11-09 12:30:17 +08:00
Lee Sang Gyu b4302b2d15 fix(package): [system] keep the whole command name in GetProcessInfo (#426)
* fix(package): [system] keep the whole command name in GetProcessInfo

* test(package): [system] cover a command name with spaces
2026-11-09 12:30:04 +08:00
Lee Sang Gyu 1715d14cc8 fix(package): [retry] use a bit shift only for power-of-two backoff bases (#427)
* fix(package): [retry] use a bit shift only for power-of-two backoff bases

* test(package): [retry] cover the multiplier of even backoff bases
2026-11-09 12:28:54 +08:00
Lee Sang Gyu 0eabb67ed4 fix(xerror): [xerror] drop leading ": " when message is empty (#423)
* fix(xerror): [xerror] drop leading ": " when message is empty

* test(xerror): [xerror] cover Wrap without a message
2026-11-09 12:27:37 +08:00
Lee Sang Gyu afc0d3c676 fix(package): [stream] do not call less with a zero value in Max and Min (#422)
* fix(package): [stream] do not call less with a zero value in Max and Min

* test(package): [stream] cover Max and Min with pointer items
2026-11-09 12:27:08 +08:00
Lee Sang Gyu 9c56f5f525 fix(package): [formatter] use the next byte unit when rounding hits base (#425)
* fix(package): [formatter] use the next byte unit when rounding hits base

* test(package): [formatter] cover byte unit rollover on rounding
2026-11-09 12:26:44 +08:00
Lee Sang Gyu 285e69edd7 fix(package): [promise] buffer the settle channels of All, Race and Any (#428)
* fix(package): [promise] buffer the settle channels of All, Race and Any

* test(package): [promise] check that All, Race and Any leave no goroutines
2026-11-09 12:26:13 +08:00
Rupayon Haldar e393f0e14f fix(package): [tree] recurse in postorder traversal (#411) 2026-11-02 10:43:54 +08:00
dudaodong 8dd57ef490 remove go report card 2026-10-31 10:30:11 +08:00
Rupayon Haldar 7e02d7e4e6 fix(package): [algorithm] keep LRU endpoint links consistent (#406) 2026-10-31 09:56:48 +08:00
Rupayon Haldar 9f7d939e5a fix(package): [tree] apply the replacement root on deletion (#407) 2026-10-31 09:56:26 +08:00
Rupayon Haldar 03695c886c fix(hashmap): preserve collision chains and bucket indexes (#408) 2026-10-31 09:54:49 +08:00
Rupayon Haldar 9c401c4043 fix(list): release the sort lock on every exit (#409) 2026-10-31 09:54:39 +08:00
Rupayon Haldar f7acd40303 fix(list): preserve receiver contents in SubList (#410) 2026-10-31 09:53:45 +08:00
Rupayon Haldar eb4b10cc15 fix(package): [link] preserve deletion bounds and backlinks (#405) 2026-10-31 09:53:29 +08:00
45 changed files with 1115 additions and 126 deletions

No files matched your search

-1
View File
@@ -6,7 +6,6 @@
![Go version](https://img.shields.io/badge/go-%3E%3Dv1.18-9cf)
[![Release](https://img.shields.io/badge/release-2.4.0-green.svg)](https://github.com/duke-git/lancet/releases)
[![GoDoc](https://godoc.org/github.com/duke-git/lancet/v2?status.svg)](https://pkg.go.dev/github.com/duke-git/lancet/v2)
[![Go Report Card](https://goreportcard.com/badge/github.com/duke-git/lancet/v2)](https://goreportcard.com/report/github.com/duke-git/lancet/v2)
[![test](https://github.com/duke-git/lancet/actions/workflows/codecov.yml/badge.svg?branch=main&event=push)](https://github.com/duke-git/lancet/actions/workflows/codecov.yml)
[![codecov](https://codecov.io/gh/duke-git/lancet/branch/main/graph/badge.svg?token=FC48T1F078)](https://codecov.io/gh/duke-git/lancet)
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/duke-git/lancet/blob/main/LICENSE)
-1
View File
@@ -6,7 +6,6 @@
![Go version](https://img.shields.io/badge/go-%3E%3Dv1.18-9cf)
[![Release](https://img.shields.io/badge/release-2.4.0-green.svg)](https://github.com/duke-git/lancet/releases)
[![GoDoc](https://godoc.org/github.com/duke-git/lancet/v2?status.svg)](https://pkg.go.dev/github.com/duke-git/lancet/v2)
[![Go Report Card](https://goreportcard.com/badge/github.com/duke-git/lancet/v2)](https://goreportcard.com/report/github.com/duke-git/lancet/v2)
[![test](https://github.com/duke-git/lancet/actions/workflows/codecov.yml/badge.svg?branch=main&event=push)](https://github.com/duke-git/lancet/actions/workflows/codecov.yml)
[![codecov](https://codecov.io/gh/duke-git/lancet/branch/main/graph/badge.svg?token=FC48T1F078)](https://codecov.io/gh/duke-git/lancet)
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/duke-git/lancet/blob/main/LICENSE)
+76
View File
@@ -0,0 +1,76 @@
package algorithm
import (
"strconv"
"testing"
)
func TestLRUCacheReuseAfterDeletingLastEntry(t *testing.T) {
cache := NewLRUCache[int, string](1)
cache.Put(1, "one")
if !cache.Delete(1) || cache.Len() != 0 {
t.Fatal("deleting the sole entry must empty the cache")
}
cache.Put(2, "two")
cache.Put(3, "three")
if cache.Len() != 1 {
t.Fatalf("Len() = %d, want capacity 1", cache.Len())
}
if _, ok := cache.Get(2); ok {
t.Fatal("the least recently used replacement entry was not evicted")
}
if value, ok := cache.Get(3); !ok || value != "three" {
t.Fatalf("Get(3) = %q, %v", value, ok)
}
}
func TestLRUCacheEmptyAndReuse(t *testing.T) {
for _, capacity := range []int{0, 1, 2, 4} {
t.Run(strconv.Itoa(capacity), func(t *testing.T) {
defer func() {
if value := recover(); value != nil {
t.Errorf("cache reuse panicked: %v", value)
}
}()
cache := NewLRUCache[int, int](capacity)
for round := 0; round < 4; round++ {
for i := 0; i < capacity+2; i++ {
cache.Put(i, round*10+i)
if cache.Len() > capacity {
t.Fatalf("Len() = %d exceeds capacity %d", cache.Len(), capacity)
}
}
for i := 0; i < capacity+2; i++ {
cache.Delete(i)
}
if cache.Len() != 0 {
t.Fatalf("Len() = %d after deleting every key", cache.Len())
}
}
})
}
}
func TestLRUCacheEndpointDeletionKeepsRecency(t *testing.T) {
cache := NewLRUCache[int, int](3)
cache.Put(1, 10)
cache.Put(2, 20)
cache.Put(3, 30)
cache.Delete(1)
cache.Delete(3)
cache.Put(4, 40)
cache.Put(5, 50)
cache.Get(2)
cache.Put(6, 60)
if cache.Len() != 3 {
t.Fatalf("Len() = %d, want 3", cache.Len())
}
if _, ok := cache.Get(4); ok {
t.Fatal("Get(2) should make 4 the least recently used entry")
}
for key, want := range map[int]int{2: 20, 5: 50, 6: 60} {
if got, ok := cache.Get(key); !ok || got != want {
t.Fatalf("Get(%d) = %d, %v; want %d", key, got, ok, want)
}
}
}
+6 -4
View File
@@ -102,12 +102,14 @@ func (l *LRUCache[K, V]) addNode(node *lruNode[K, V]) {
}
func (l *LRUCache[K, V]) deleteNode(node *lruNode[K, V]) K {
if node == l.tail {
l.tail = l.tail.pre
} else if node == l.head {
l.head = l.head.next
if node.pre == nil {
l.head = node.next
} else {
node.pre.next = node.next
}
if node.next == nil {
l.tail = node.pre
} else {
node.next.pre = node.pre
}
return node.key
+10 -1
View File
@@ -90,7 +90,16 @@ func (c *Channel[T]) Take(ctx context.Context, valueStream <-chan T, number int)
select {
case <-ctx.Done():
return
case takeStream <- <-valueStream:
case v, ok := <-valueStream:
if !ok {
return
}
select {
case <-ctx.Done():
return
case takeStream <- v:
}
}
}
}()
+28
View File
@@ -82,6 +82,34 @@ func TestTake(t *testing.T) {
assert.Equal(3, <-intStream)
}
func TestTake_sourceClosedOrCanceled(t *testing.T) {
t.Parallel()
assert := internal.NewAssert(t, "TestTake_sourceClosedOrCanceled")
c := NewChannel[int]()
// the source ends before number values: no zero values are produced
ctx := context.Background()
var got []int
for v := range c.Take(ctx, c.Generate(ctx, 1, 2), 5) {
got = append(got, v)
}
assert.Equal([]int{1, 2}, got)
// the source is idle: canceling ctx closes the stream
ctx2, cancel := context.WithCancel(context.Background())
idle := make(chan int)
stream := c.Take(ctx2, idle, 3)
cancel()
select {
case _, ok := <-stream:
assert.Equal(false, ok)
case <-time.After(2 * time.Second):
t.Fatal("Take did not stop after the context was canceled")
}
}
func TestFanIn(t *testing.T) {
t.Parallel()
assert := internal.NewAssert(t, "TestFanIn")
+4 -1
View File
@@ -214,14 +214,17 @@ func NewTryKeyedLocker[K comparable]() *TryKeyedLocker[K] {
// Play: https://go.dev/play/p/VG9qLvyetE2
func (l *TryKeyedLocker[K]) TryLock(key K) bool {
l.mu.Lock()
defer l.mu.Unlock()
lock, ok := l.locks[key]
if !ok {
lock = &casMutex{}
l.locks[key] = lock
}
l.mu.Unlock()
// TryLock must run while l.mu is held. Otherwise Unlock can delete this entry
// from the map before the lock is taken, and a later TryLock for the same key
// would create a second entry and succeed while this one is still held.
return lock.TryLock()
}
+32
View File
@@ -4,6 +4,7 @@ import (
"context"
"strconv"
"sync"
"sync/atomic"
"testing"
"time"
@@ -228,3 +229,34 @@ func TestTryKeyedLocker_ParallelTry(t *testing.T) {
assert.Equal(5, count)
assert.Equal(0, len(locker.locks))
}
func TestTryKeyedLocker_MutualExclusionUnderContention(t *testing.T) {
t.Parallel()
locker := NewTryKeyedLocker[string]()
var holders, violations int32
var wg sync.WaitGroup
for g := 0; g < 8; g++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 20000; i++ {
if !locker.TryLock("key") {
continue
}
if atomic.AddInt32(&holders, 1) > 1 {
atomic.AddInt32(&violations, 1)
}
atomic.AddInt32(&holders, -1)
locker.Unlock("key")
}
}()
}
wg.Wait()
if v := atomic.LoadInt32(&violations); v != 0 {
t.Fatalf("the lock was held by more than one goroutine %d times", v)
}
}
@@ -0,0 +1,138 @@
package datastructure_test
import (
"fmt"
"reflect"
"testing"
hashmap "github.com/duke-git/lancet/v2/datastructure/hashmap"
)
type collisionKey string
func TestHashMapCollisionStorage(t *testing.T) {
cases := map[string]func(*testing.T){
"distinct colliding keys": func(t *testing.T) {
h := hashmap.NewHashMapWithCapacity(0, 1)
keys := []any{1, "1", collisionKey("1")}
for i, k := range keys {
h.Put(k, i+10)
}
for i, k := range keys {
if h.Get(k) != i+10 || !h.Contains(k) {
t.Fatalf("lost key %T(%v): %v", k, k, h.Get(k))
}
}
if h.Size() != 3 {
t.Fatalf("size=%d", h.Size())
}
},
"overwrite cardinality": func(t *testing.T) {
h := hashmap.NewHashMap()
h.Put("same", 1)
h.Put("same", 2)
if h.Size() != 1 || !reflect.DeepEqual(h.Keys(), []any{"same"}) || !reflect.DeepEqual(h.Values(), []any{2}) {
t.Fatalf("overwrite size/keys/values: %d %v %v", h.Size(), h.Keys(), h.Values())
}
},
"delete absent colliding key": func(t *testing.T) {
h := hashmap.NewHashMap()
h.Put(1, "kept")
h.Delete("1")
if h.Size() != 1 || h.Get(1) != "kept" {
t.Fatalf("absent delete removed another key: size=%d get=%v", h.Size(), h.Get(1))
}
},
"delete every chain position": func(t *testing.T) {
for _, remove := range []any{1, "1", collisionKey("1")} {
h := hashmap.NewHashMapWithCapacity(0, 4)
keys := []any{1, "1", collisionKey("1")}
for i, k := range keys {
h.Put(k, i)
}
h.Delete(remove)
if h.Contains(remove) || h.Size() != 2 {
t.Fatalf("deletion failed: %T size=%d", remove, h.Size())
}
for i, k := range keys {
if !reflect.DeepEqual(k, remove) && h.Get(k) != i {
t.Fatalf("delete lost other key: %T", k)
}
}
}
},
"delete preserves bucket indexes": func(t *testing.T) {
h := hashmap.NewHashMap()
for i := 0; i < 100; i++ {
h.Put(fmt.Sprintf("key-%d", i), i)
}
for i := 0; i < 100; i += 2 {
h.Delete(fmt.Sprintf("key-%d", i))
}
for i := 1; i < 100; i += 2 {
if h.Get(fmt.Sprintf("key-%d", i)) != i {
t.Fatalf("lost untouched bucket key %d", i)
}
}
if h.Size() != 50 {
t.Fatalf("size=%d", h.Size())
}
},
"resize and enumerate chains": func(t *testing.T) {
h := hashmap.NewHashMapWithCapacity(0, 1)
h.Put(1, 1)
h.Put("1", 2)
h.Put(collisionKey("1"), 3)
for i := 2; i <= 64; i++ {
h.Put(i, i)
}
seen := map[string]any{}
h.Iterate(func(k, v any) { seen[fmt.Sprintf("%T:%v", k, k)] = v })
if h.Size() != 66 || len(seen) != 66 || len(h.Keys()) != 66 || len(h.Values()) != 66 {
t.Fatalf("size=%d enumerated=%d", h.Size(), len(seen))
}
if seen["string:1"] != 2 || h.Get(collisionKey("1")) != 3 {
t.Fatal("collision chain lost during growth")
}
},
"filter colliding values": func(t *testing.T) {
h := hashmap.NewHashMapWithCapacity(0, 4)
h.Put(1, 1)
h.Put("1", 2)
h.Put(collisionKey("1"), 3)
f := h.FilterByValue(func(v any) bool { return v.(int)%2 == 1 })
if f == nil || f.Size() != 2 || f.Get(1) != 1 || f.Get(collisionKey("1")) != 3 || f.Contains("1") {
t.Fatal("filter skipped or corrupted collision chain")
}
if h.FilterByValue(func(any) bool { return false }) != nil {
t.Fatal("empty-filter nil contract changed")
}
},
"zero capacity and deep equal keys": func(t *testing.T) {
h := hashmap.NewHashMapWithCapacity(0, 0)
if h.Get("missing") != nil || h.Contains("missing") {
t.Fatal("empty lookup")
}
h.Delete("missing")
h.Put([]int{1, 2}, "first")
h.Put([]int{1, 2}, "second")
if h.Get([]int{1, 2}) != "second" || h.Size() != 1 {
t.Fatal("deep equal overwrite failed")
}
h.Delete([]int{1, 2})
if h.Size() != 0 {
t.Fatal("deep equal delete failed")
}
},
}
for name, fn := range cases {
t.Run(name, func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("public map operation panicked: %v", r)
}
}()
fn(t)
})
}
}
+52 -49
View File
@@ -44,6 +44,9 @@ func NewHashMapWithCapacity(size, capacity uint64) *HashMap {
// Get return the value of given key in hashmap
func (hm *HashMap) Get(key any) any {
if hm.size == 0 {
return nil
}
hashValue := hm.hash(key)
node := hm.table[hashValue]
for node != nil {
@@ -66,41 +69,55 @@ func (hm *HashMap) GetOrDefault(key any, defaultValue any) any {
// Put new key value in hashmap
func (hm *HashMap) Put(key any, value any) {
hm.putValue(hm.hash(key), key, value)
}
func (hm *HashMap) putValue(hash uint64, key, value any) {
if hm.capacity == 0 {
hm.capacity = defaultMapCapacity
hm.table = make([]*mapNode, defaultMapCapacity)
}
hm.putValue(hm.hash(key), key, value)
}
node := hm.table[hash]
if node == nil {
hm.table[hash] = newMapNode(key, value)
} else if node.key == key {
hm.table[hash] = newMapNodeWithNext(key, value, node)
} else {
hm.resize()
hm.putValue(hash, value, value)
func (hm *HashMap) putValue(hash uint64, key, value any) {
for node := hm.table[hash]; node != nil; node = node.next {
if reflect.DeepEqual(node.key, key) {
node.value = value
return
}
}
if hm.size >= hm.capacity {
hm.resize()
hash = hm.hash(key)
}
hm.table[hash] = newMapNodeWithNext(key, value, hm.table[hash])
hm.size++
}
// Delete item by given key in hashmap
func (hm *HashMap) Delete(key any) {
hash := hm.hash(key)
node := hm.table[hash]
if node == nil {
if hm.size == 0 {
return
}
hm.table = append(hm.table[:hash], hm.table[hash+1:]...)
hm.size--
hash := hm.hash(key)
var previous *mapNode
for node := hm.table[hash]; node != nil; node = node.next {
if reflect.DeepEqual(node.key, key) {
if previous == nil {
hm.table[hash] = node.next
} else {
previous.next = node.next
}
hm.size--
return
}
previous = node
}
}
// Contains checks if given key is in hashmap or not
func (hm *HashMap) Contains(key any) bool {
if hm.size == 0 {
return false
}
node := hm.table[hm.hash(key)]
for node != nil {
if reflect.DeepEqual(node.key, key) {
@@ -113,12 +130,10 @@ func (hm *HashMap) Contains(key any) bool {
// Iterate executes iteratee funcation for every key and value pair of hashmap (random order)
func (hm *HashMap) Iterate(iteratee func(key, value any)) {
if hm.size > 0 {
for i := 0; i < len(hm.table); i++ {
item := hm.table[i]
if item != nil {
iteratee(item.key, item.value)
}
for _, node := range hm.table {
for node != nil {
iteratee(node.key, node.value)
node = node.next
}
}
}
@@ -128,17 +143,14 @@ func (hm *HashMap) Iterate(iteratee func(key, value any)) {
// otherwise it returns the HashMap with selected values.
func (hm *HashMap) FilterByValue(perdicate func(value any) bool) *HashMap {
var filteredHM *HashMap
if hm.size > 0 {
for i := 0; i < len(hm.table); i++ {
item := hm.table[i]
if item != nil && perdicate(item.value) {
if filteredHM == nil {
filteredHM = NewHashMap()
}
filteredHM.Put(item.key, item.value)
hm.Iterate(func(key, value any) {
if perdicate(value) {
if filteredHM == nil {
filteredHM = NewHashMap()
}
filteredHM.Put(key, value)
}
}
})
return filteredHM
}
@@ -172,18 +184,16 @@ func (hm *HashMap) Values() []any {
func (hm *HashMap) resize() {
hm.capacity <<= 1
tempTable := hm.table
hm.table = make([]*mapNode, hm.capacity)
for i := 0; i < len(tempTable); i++ {
node := tempTable[i]
if node == nil {
continue
for _, node := range tempTable {
for node != nil {
next := node.next
hash := hm.hash(node.key)
node.next = hm.table[hash]
hm.table[hash] = node
node = next
}
hm.table[hm.hash(node.key)] = node
}
}
@@ -201,13 +211,6 @@ func (hm *HashMap) hash(key any) uint64 {
return (hm.capacity - 1) & (hashValue ^ (hashValue >> 16))
}
func newMapNode(key, value any) *mapNode {
return &mapNode{
key: key,
value: value,
}
}
func newMapNodeWithNext(key, value any, next *mapNode) *mapNode {
return &mapNode{
key: key,
+10 -7
View File
@@ -87,6 +87,7 @@ func (dl *DoublyLink[T]) InsertAt(index int, value T) {
newNode := datastructure.NewLinkNode(value)
newNode.Next = current.Next
newNode.Pre = current
current.Next.Pre = newNode
current.Next = newNode
dl.length++
@@ -106,7 +107,9 @@ func (dl *DoublyLink[T]) DeleteAtHead() {
current := dl.Head
dl.Head = current.Next
dl.Head.Pre = nil
if dl.Head != nil {
dl.Head.Pre = nil
}
dl.length--
}
@@ -119,6 +122,7 @@ func (dl *DoublyLink[T]) DeleteAtTail() {
current := dl.Head
if current.Next == nil {
dl.DeleteAtHead()
return
}
for current.Next.Next != nil {
@@ -132,27 +136,26 @@ func (dl *DoublyLink[T]) DeleteAtTail() {
// DeleteAt delete value in doubly linklist at index
// param `index` should be [0, len(DoublyLink)-1]
func (dl *DoublyLink[T]) DeleteAt(index int) {
if dl.Head == nil {
if dl.Head == nil || index < 0 || index >= dl.length {
return
}
current := dl.Head
if current.Next == nil || index == 0 {
if index == 0 {
dl.DeleteAtHead()
return
}
if index == dl.length-1 {
dl.DeleteAtTail()
}
if index < 0 || index > dl.length-1 {
return
}
current := dl.Head
i := 0
for current != nil {
if i == index-1 {
current.Next = current.Next.Next
current.Next.Pre = current
dl.length--
return
}
@@ -0,0 +1,115 @@
package datastructure
import (
"reflect"
"testing"
)
type mutableIntLink interface {
InsertAtHead(int)
InsertAtTail(int)
DeleteAtHead()
DeleteAtTail()
DeleteAt(int)
Reverse()
Values() []int
Size() int
IsEmpty() bool
}
func TestLinkDeletionBoundaries(t *testing.T) {
for name, newLink := range map[string]func() mutableIntLink{
"singly": func() mutableIntLink { return NewSinglyLink[int]() },
"doubly": func() mutableIntLink { return NewDoublyLink[int]() },
} {
t.Run(name, func(t *testing.T) {
for _, tt := range []struct {
name string
size int
delete func(mutableIntLink)
want []int
}{
{"empty head", 0, func(l mutableIntLink) { l.DeleteAtHead() }, []int{}},
{"empty tail", 0, func(l mutableIntLink) { l.DeleteAtTail() }, []int{}},
{"empty index", 0, func(l mutableIntLink) { l.DeleteAt(0) }, []int{}},
{"singleton head", 1, func(l mutableIntLink) { l.DeleteAtHead() }, []int{}},
{"singleton tail", 1, func(l mutableIntLink) { l.DeleteAtTail() }, []int{}},
{"singleton index", 1, func(l mutableIntLink) { l.DeleteAt(0) }, []int{}},
{"singleton negative index", 1, func(l mutableIntLink) { l.DeleteAt(-1) }, []int{1}},
{"singleton large index", 1, func(l mutableIntLink) { l.DeleteAt(1) }, []int{1}},
{"two item head index", 2, func(l mutableIntLink) { l.DeleteAt(0) }, []int{2}},
{"two item tail index", 2, func(l mutableIntLink) { l.DeleteAt(1) }, []int{1}},
{"middle index", 4, func(l mutableIntLink) { l.DeleteAt(1) }, []int{1, 3, 4}},
{"tail index", 4, func(l mutableIntLink) { l.DeleteAt(3) }, []int{1, 2, 3}},
{"negative index", 4, func(l mutableIntLink) { l.DeleteAt(-1) }, []int{1, 2, 3, 4}},
{"large index", 4, func(l mutableIntLink) { l.DeleteAt(4) }, []int{1, 2, 3, 4}},
} {
t.Run(tt.name, func(t *testing.T) {
defer func() {
if value := recover(); value != nil {
t.Errorf("list mutation panicked: %v", value)
}
}()
l := newLink()
for i := 1; i <= tt.size; i++ {
l.InsertAtTail(i)
}
tt.delete(l)
if got := l.Values(); !reflect.DeepEqual(got, tt.want) {
t.Fatalf("Values() = %v, want %v", got, tt.want)
}
if l.Size() != len(tt.want) || l.IsEmpty() != (len(tt.want) == 0) {
t.Fatalf("Size() = %d, IsEmpty() = %v, want length %d", l.Size(), l.IsEmpty(), len(tt.want))
}
if doubly, ok := l.(*DoublyLink[int]); ok {
current := doubly.Head
for i := 0; current != nil; i++ {
if i >= l.Size() {
t.Fatal("cycle in list after deletion")
}
if current.Next != nil && current.Next.Pre != current {
t.Fatal("deletion did not preserve reciprocal links")
}
current = current.Next
}
}
l.Reverse()
wantReverse := make([]int, len(tt.want))
for i, value := range tt.want {
wantReverse[len(tt.want)-1-i] = value
}
if got := l.Values(); !reflect.DeepEqual(got, wantReverse) {
t.Fatalf("Reverse Values() = %v, want %v", got, wantReverse)
}
l.Reverse()
l.InsertAtHead(9)
if got := l.Values(); !reflect.DeepEqual(got, append([]int{9}, tt.want...)) {
t.Fatalf("reused list Values() = %v", got)
}
})
}
})
}
}
func TestDoublyLinkMiddleMutationBacklinks(t *testing.T) {
l := NewDoublyLink[int]()
l.InsertAtTail(1)
l.InsertAtTail(3)
l.InsertAt(1, 2)
if l.Head.Next.Next.Pre != l.Head.Next {
t.Fatal("middle insertion did not update the next node's Pre link")
}
l.Reverse()
if got := l.Values(); !reflect.DeepEqual(got, []int{3, 2, 1}) {
t.Fatalf("Reverse() after insertion = %v", got)
}
l.DeleteAt(1)
if l.Head.Next.Pre != l.Head {
t.Fatal("middle deletion did not update the next node's Pre link")
}
l.Reverse()
if got := l.Values(); !reflect.DeepEqual(got, []int{1, 3}) {
t.Fatalf("Reverse() after deletion = %v", got)
}
}
+5 -6
View File
@@ -103,6 +103,7 @@ func (sl *SinglyLink[T]) DeleteAtTail() {
current := sl.Head
if current.Next == nil {
sl.DeleteAtHead()
return
}
for current.Next.Next != nil {
@@ -116,22 +117,20 @@ func (sl *SinglyLink[T]) DeleteAtTail() {
// DeleteAt delete value in singly linklist at index
// param `index` should be [0, len(SinglyLink)-1]
func (sl *SinglyLink[T]) DeleteAt(index int) {
if sl.Head == nil {
if sl.Head == nil || index < 0 || index >= sl.length {
return
}
current := sl.Head
if current.Next == nil || index == 0 {
if index == 0 {
sl.DeleteAtHead()
return
}
if index == sl.length-1 {
sl.DeleteAtTail()
}
if index < 0 || index > sl.length-1 {
return
}
current := sl.Head
i := 0
for current != nil {
if i == index-1 {
+1 -1
View File
@@ -354,6 +354,7 @@ func (c *CopyOnWriteList[T]) ForEach(f func(T)) {
func (c *CopyOnWriteList[T]) Sort(compare func(o1 T, o2 T) bool) {
lock := c.lock
lock.Lock()
defer lock.Unlock()
list := c.getList()
sort.Slice(list, func(i, j int) bool {
@@ -374,6 +375,5 @@ func (c *CopyOnWriteList[T]) SubList(start int, end int) (newList []T) {
}
newList = make([]T, end-start)
copy(newList, list[start:end])
c.setList(newList)
return
}
+69
View File
@@ -0,0 +1,69 @@
package datastructure_test
import (
"reflect"
"testing"
"time"
list "github.com/duke-git/lancet/v2/datastructure/list"
)
func TestCopyOnWriteSortReleasesLock(t *testing.T) {
for _, tc := range []struct {
name string
values []int
panicCompare bool
sort bool
}{
{"sorted values", []int{3, 1, 2}, false, true},
{"empty values", []int{}, false, true},
{"comparator panic", []int{3, 1, 2}, true, true},
{"without sort control", []int{1, 2, 3}, false, false},
} {
t.Run(tc.name, func(t *testing.T) {
c := list.NewCopyOnWriteList(tc.values)
marker := new(int)
if tc.sort {
func() {
defer func() {
r := recover()
if tc.panicCompare {
if r != marker {
t.Errorf("panic identity changed: %v", r)
}
} else if r != nil {
t.Errorf("unexpected panic: %v", r)
}
}()
c.Sort(func(a, b int) bool {
if tc.panicCompare {
panic(marker)
}
return a < b
})
}()
}
if tc.sort && !tc.panicCompare {
got := []int{}
c.ForEach(func(v int) { got = append(got, v) })
want := []int{1, 2, 3}
if len(tc.values) == 0 {
want = []int{}
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("sort got=%v want=%v", got, want)
}
}
done := make(chan struct{})
go func() { c.Add(4); close(done) }()
select {
case <-done:
if c.Size() != len(tc.values)+1 {
t.Fatal("next mutation did not persist")
}
case <-time.After(time.Second):
t.Fatal("Sort left the mutation lock held")
}
})
}
}
@@ -0,0 +1,40 @@
package datastructure_test
import (
list "github.com/duke-git/lancet/v2/datastructure/list"
"reflect"
"testing"
)
func TestCopyOnWriteSubListPreservesReceiver(t *testing.T) {
for _, tc := range []struct {
name string
start, end int
want []int
}{
{"middle", 1, 3, []int{2, 3}}, {"empty range", 2, 2, []int{}}, {"prefix", 0, 2, []int{1, 2}}, {"suffix", 2, 4, []int{3, 4}}, {"full control", 0, 4, []int{1, 2, 3, 4}}, {"invalid control", 3, 1, []int{}},
} {
t.Run(tc.name, func(t *testing.T) {
c := list.NewCopyOnWriteList([]int{1, 2, 3, 4})
got := c.SubList(tc.start, tc.end)
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("sublist=%v want%v", got, tc.want)
}
values := []int{}
c.ForEach(func(v int) { values = append(values, v) })
if c.Size() != 4 || !reflect.DeepEqual(values, []int{1, 2, 3, 4}) {
t.Fatalf("SubList discarded source values: %v", values)
}
if len(got) > 0 {
got[0] = 99
}
if *c.Get(0) != 1 || *c.Get(1) != 2 {
t.Fatal("returned slice aliases receiver")
}
c.Add(5)
if c.Size() != 5 || *c.Get(4) != 5 {
t.Fatal("later append changed")
}
})
}
}
+1 -1
View File
@@ -40,7 +40,7 @@ func (t *BSTree[T]) Insert(data T) {
// DeletetNode delete data into BSTree
func (t *BSTree[T]) Delete(data T) {
deleteTreeNode(t.root, data, t.comparator)
t.root = deleteTreeNode(t.root, data, t.comparator)
}
// NodeLevel get node level in BSTree
+1 -1
View File
@@ -49,7 +49,7 @@ func TestBSTree_PostOrderTraverse(t *testing.T) {
bstree.Insert(4)
acturl := bstree.PostOrderTraverse()
assert.Equal([]int{5, 2, 4, 7, 6}, acturl)
assert.Equal([]int{4, 2, 5, 7, 6}, acturl)
}
func TestBSTree_InOrderTraverse(t *testing.T) {
@@ -0,0 +1,50 @@
package datastructure_test
import (
"reflect"
"testing"
tree "github.com/duke-git/lancet/v2/datastructure/tree"
)
type postorderComparator struct{}
func (postorderComparator) Compare(a, b any) int {
x, y := a.(int), b.(int)
if x < y {
return -1
}
if x > y {
return 1
}
return 0
}
func TestBSTreePostOrderVisitsDescendantsBeforeParents(t *testing.T) {
for _, tc := range []struct {
name string
input, want []int
}{
{"balanced", []int{4, 2, 6, 1, 3, 5, 7}, []int{1, 3, 2, 5, 7, 6, 4}},
{"left chain", []int{5, 4, 3, 2}, []int{2, 3, 4, 5}},
{"right chain", []int{1, 2, 3, 4}, []int{4, 3, 2, 1}},
{"duplicate values", []int{5, 5, 5, 6}, []int{6, 5, 5, 5}},
{"single root", []int{7}, []int{7}},
} {
t.Run(tc.name, func(t *testing.T) {
tr := tree.NewBSTree(tc.input[0], postorderComparator{})
for _, v := range tc.input[1:] {
tr.Insert(v)
}
if got := tr.PostOrderTraverse(); !reflect.DeepEqual(got, tc.want) {
t.Fatalf("postorder=%v want %v", got, tc.want)
}
})
}
}
func TestBSTreePostOrderEmptyControl(t *testing.T) {
tr := tree.NewBSTree(7, postorderComparator{})
tr.Delete(7)
if got := tr.PostOrderTraverse(); len(got) != 0 {
t.Fatalf("empty traversal=%v", got)
}
}
+66
View File
@@ -0,0 +1,66 @@
package datastructure
import (
"sort"
"strconv"
"testing"
)
func TestBSTreeDeleteReplacesRoot(t *testing.T) {
for i, tt := range []struct {
values []int
remove int
want []int
}{
{nil, 4, nil},
{[]int{2, 1, 3}, 4, []int{1, 2, 3}},
{[]int{6, 5, 7}, 4, []int{5, 6, 7}},
{[]int{2, 6, 1, 3, 5, 7}, 4, []int{1, 2, 3, 5, 6, 7}},
{[]int{2, 6}, 9, []int{2, 4, 6}},
{[]int{2, 6}, 2, []int{4, 6}},
{[]int{2, 6, 1}, 2, []int{1, 4, 6}},
{[]int{2, 6, 1, 3}, 2, []int{1, 3, 4, 6}},
} {
t.Run(strconv.Itoa(i), func(t *testing.T) {
tree := NewBSTree(4, &intComparator{})
for _, value := range tt.values {
tree.Insert(value)
}
tree.Delete(tt.remove)
checkBSTValues(t, tree, tt.want)
tree.Delete(tt.remove)
checkBSTValues(t, tree, tt.want)
tree.Insert(8)
want := append(append([]int(nil), tt.want...), 8)
sort.Ints(want)
checkBSTValues(t, tree, want)
})
}
}
func checkBSTValues(t *testing.T, tree *BSTree[int], want []int) {
t.Helper()
inOrder := tree.InOrderTraverse()
if !sort.IntsAreSorted(inOrder) {
t.Fatalf("in-order traversal is not sorted: %v", inOrder)
}
for name, got := range map[string][]int{
"in-order": inOrder,
"pre-order": tree.PreOrderTraverse(),
"post-order": tree.PostOrderTraverse(),
"level-order": tree.LevelOrderTraverse(),
} {
sort.Ints(got)
if len(got) != len(want) {
t.Fatalf("%s traversal = %v, want values %v", name, got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("%s traversal = %v, want values %v", name, got, want)
}
}
}
if len(want) == 0 && tree.Depth() != 0 {
t.Fatalf("empty tree depth = %d", tree.Depth())
}
}
+5 -3
View File
@@ -21,8 +21,8 @@ func preOrderTraverse[T any](node *datastructure.TreeNode[T]) []T {
func postOrderTraverse[T any](node *datastructure.TreeNode[T]) []T {
data := []T{}
if node != nil {
data = append(data, preOrderTraverse(node.Left)...)
data = append(data, preOrderTraverse(node.Right)...)
data = append(data, postOrderTraverse(node.Left)...)
data = append(data, postOrderTraverse(node.Right)...)
data = append(data, node.Value)
}
return data
@@ -69,6 +69,9 @@ func inOrderTraverse[T any](node *datastructure.TreeNode[T]) []T {
// }
func levelOrderTraverse[T any](root *datastructure.TreeNode[T], traversal *[]T) {
if root == nil {
return
}
var q []*datastructure.TreeNode[T] // queue
var n *datastructure.TreeNode[T] // temp node
@@ -102,7 +105,6 @@ func insertTreeNode[T any](rootNode, newNode *datastructure.TreeNode[T], compara
}
}
// todo, delete root node failed
func deleteTreeNode[T any](node *datastructure.TreeNode[T], data T, comparator constraints.Comparator) *datastructure.TreeNode[T] {
if node == nil {
return nil
+2 -4
View File
@@ -188,15 +188,13 @@ func GetTodayEndTime() string {
// GetZeroHourTimestamp return timestamp of zero hour (timestamp of 00:00).
// Play: https://go.dev/play/p/QmL2oIaGE3q
func GetZeroHourTimestamp() int64 {
ts := time.Now().Format("2006-01-02")
t, _ := time.Parse("2006-01-02", ts)
return t.UTC().Unix() - 8*3600
return BeginOfDay(time.Now()).Unix()
}
// GetNightTimestamp return timestamp of zero hour (timestamp of 23:59).
// Play: https://go.dev/play/p/UolysR3MYP1
func GetNightTimestamp() int64 {
return GetZeroHourTimestamp() + 86400 - 1
return EndOfDay(time.Now()).Unix()
}
// FormatTimeToStr convert time to string.
+25
View File
@@ -943,3 +943,28 @@ func TestBetweenSeconds(t *testing.T) {
assert.Equal(int64(86400), result1)
assert.Equal(int64(-86400), result2)
}
func TestGetZeroHourAndNightTimestampLocalZone(t *testing.T) {
// not parallel: this test replaces time.Local
assert := internal.NewAssert(t, "TestGetZeroHourAndNightTimestampLocalZone")
oldLocal := time.Local
defer func() { time.Local = oldLocal }()
for _, offset := range []int{-5 * 3600, 0, 8 * 3600, 9 * 3600} {
time.Local = time.FixedZone("test", offset)
before := time.Now()
zero := GetZeroHourTimestamp()
night := GetNightTimestamp()
after := time.Now()
if before.Day() != after.Day() {
continue
}
assert.Equal(BeginOfDay(before).Unix(), zero)
assert.Equal(EndOfDay(before).Unix(), night)
assert.Equal(int64(86399), night-zero)
}
}
+11 -6
View File
@@ -56,12 +56,16 @@ func (eb *EventBus[T]) Subscribe(topic string, listener func(eventData T), async
listenersInterface, _ := eb.listeners.LoadOrStore(topic, []*EventListener[T]{})
listeners := listenersInterface.([]*EventListener[T])
listeners = append(listeners, el)
sort.Slice(listeners, func(i, j int) bool {
return listeners[i].priority > listeners[j].priority
// Build a new slice instead of appending in place: Publish iterates over the
// stored slice after releasing the lock, so it must never be modified.
updated := make([]*EventListener[T], len(listeners), len(listeners)+1)
copy(updated, listeners)
updated = append(updated, el)
sort.Slice(updated, func(i, j int) bool {
return updated[i].priority > updated[j].priority
})
eb.listeners.Store(topic, listeners)
eb.listeners.Store(topic, updated)
}
// Unsubscribe unsubscribes from an event with a specific event topic and listener function.
@@ -91,10 +95,11 @@ func (eb *EventBus[T]) Unsubscribe(topic string, listener func(eventData T)) {
// Publish publishes an event with a specific event topic and data payload.
// Play: https://go.dev/play/p/gHTtVexFSH9
func (eb *EventBus[T]) Publish(event Event[T]) {
// The lock is only held to read the listeners, so that a listener can call
// Subscribe, Unsubscribe or Publish without deadlocking.
eb.mu.RLock()
defer eb.mu.RUnlock()
listenersInterface, exists := eb.listeners.Load(event.Topic)
eb.mu.RUnlock()
if !exists {
return
}
+55
View File
@@ -220,3 +220,58 @@ func TestEventBus_GetEvents(t *testing.T) {
assert.Equal(2, len(events))
assert.Equal([]string{"event1", "event2"}, events)
}
func TestEventBus_ListenerCanChangeSubscriptions(t *testing.T) {
t.Parallel()
assert := internal.NewAssert(t, "TestEventBus_ListenerCanChangeSubscriptions")
eb := NewEventBus[int]()
var self func(eventData int)
self = func(eventData int) {
eb.Unsubscribe("event1", self)
eb.Subscribe("event2", func(eventData int) {}, false, 0, nil)
}
eb.Subscribe("event1", self, false, 0, nil)
done := make(chan struct{})
go func() {
eb.Publish(Event[int]{Topic: "event1", Payload: 1})
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("Publish did not return when a listener changed the subscriptions")
}
assert.Equal(0, eb.GetListenersCount("event1"))
assert.Equal(1, eb.GetListenersCount("event2"))
}
func TestEventBus_PublishWhileSubscribing(t *testing.T) {
t.Parallel()
eb := NewEventBus[int]()
eb.Subscribe("event1", func(eventData int) {}, false, 1, nil)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
for i := 0; i < 200; i++ {
eb.Publish(Event[int]{Topic: "event1", Payload: i})
}
}()
go func() {
defer wg.Done()
for i := 0; i < 200; i++ {
eb.Subscribe("event1", func(eventData int) {}, false, i%7, nil)
}
}()
wg.Wait()
}
+17 -2
View File
@@ -92,7 +92,7 @@ func DecimalBytes(size float64, precision ...int) string {
pointPosition = precision[0]
}
size, unit := calculateByteSize(size, 1000.0, decimalByteUnits)
size, unit := calculateRoundedByteSize(size, 1000.0, decimalByteUnits, pointPosition)
return roundToToString(size, pointPosition) + unit
}
@@ -106,7 +106,7 @@ func BinaryBytes(size float64, precision ...int) string {
pointPosition = precision[0]
}
size, unit := calculateByteSize(size, 1024.0, binaryByteUnits)
size, unit := calculateRoundedByteSize(size, 1024.0, binaryByteUnits, pointPosition)
return roundToToString(size, pointPosition) + unit
}
@@ -121,6 +121,21 @@ func calculateByteSize(size float64, base float64, byteUnits []string) (float64,
return size, byteUnits[i]
}
// calculateRoundedByteSize works like calculateByteSize, but moves to the next unit
// when the value rounds up to the base at the given (non-negative) precision (e.g. 999999 bytes
// with precision 2 gives 1MB instead of 1000KB).
func calculateRoundedByteSize(size float64, base float64, byteUnits []string, precision int) (float64, string) {
size, unit := calculateByteSize(size, base, byteUnits)
i := 0
for byteUnits[i] != unit {
i++
}
if precision >= 0 && i < len(byteUnits)-1 && mathutil.RoundToFloat(size, precision) >= base {
return size / base, byteUnits[i+1]
}
return size, unit
}
func roundToToString(x float64, max ...int) string {
pointPosition := 4
if len(max) > 0 {
+7
View File
@@ -28,6 +28,10 @@ func TestDecimalBytes(t *testing.T) {
assert.Equal("4MB", DecimalBytes(4000000, 0))
assert.Equal("4MB", DecimalBytes(4000000, 1))
assert.Equal("1MB", DecimalBytes(999999, 2))
assert.Equal("1GB", DecimalBytes(999999999, 2))
assert.Equal("999.99KB", DecimalBytes(999990, 2))
}
func TestBinaryBytes(t *testing.T) {
@@ -43,6 +47,9 @@ func TestBinaryBytes(t *testing.T) {
assert.Equal("10KiB", BinaryBytes(10240, 0))
assert.Equal("10KiB", BinaryBytes(10240, 1))
assert.Equal("10KiB", BinaryBytes(10240, 2))
assert.Equal("1MiB", BinaryBytes(1048575, 2))
assert.Equal("1023.99KiB", BinaryBytes(1048566, 2))
}
func TestParseDecimalBytes(t *testing.T) {
+5 -3
View File
@@ -32,9 +32,11 @@ func Comma[T constraints.Float | constraints.Integer | string](value T, prefixSy
numString = numString[1:]
}
index := strings.Index(numString, ".")
if index == -1 {
index = len(numString)
// Only the leading run of digits gets commas, so that an exponent ("1e+21")
// or any other suffix that ParseFloat accepts is left as it is.
index := 0
for index < len(numString) && numString[index] >= '0' && numString[index] <= '9' {
index++
}
for index > 3 {
+6
View File
@@ -35,6 +35,12 @@ func TestComma(t *testing.T) {
assert.Equal("-999", Comma(-999, ""))
assert.Equal("-1,000", Comma(-1000, ""))
assert.Equal("-1,234,567", Comma(-1234567, ""))
assert.Equal("1e+21", Comma("1e+21", ""))
assert.Equal("$12,345,678e3", Comma("12345678e3", "$"))
assert.Equal("1_000", Comma("1_000", ""))
assert.Equal("1,234,567.", Comma("1234567.", ""))
assert.Equal(".12345", Comma(".12345", ""))
}
func TestPretty(t *testing.T) {
+9 -1
View File
@@ -706,10 +706,18 @@ func ToMarkdownTable(data []map[string]interface{}, headerMap map[string]string,
headers = make([]string, len(columnOrder))
copy(headers, columnOrder)
} else {
// 否则按自然顺序提取headers
// 否则按每行键的字典序提取headers
seen := make(map[string]bool)
for _, row := range data {
// Map iteration order is random, so visit the keys of each row in sorted
// order to get the same columns in the same order on every call.
keys := make([]string, 0, len(row))
for k := range row {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if !seen[k] {
seen[k] = true
headers = append(headers, k)
+20
View File
@@ -997,3 +997,23 @@ func TestToMarkdownTableWithHeaderMap(t *testing.T) {
fmt.Printf("%s", result)
// 验证中文表头是否正确显示
}
func TestToMarkdownTableDefaultColumnOrder(t *testing.T) {
t.Parallel()
data := []map[string]interface{}{
{"name": "Alice", "age": 25, "salary": 50000, "city": "Seoul"},
{"name": "Bob", "zip": "04524", "age": 30},
}
expected := "| age | city | name | salary | zip |\n" +
"|---|---|---|---|---|\n" +
"| 25 | Seoul | Alice | 50,000 | |\n" +
"| 30 | | Bob | | 04524 |\n"
for i := 0; i < 50; i++ {
if result := ToMarkdownTable(data, nil, nil); result != expected {
t.Fatalf("call %d: expected:\n%s\ngot:\n%s", i, expected, result)
}
}
}
+13 -4
View File
@@ -452,9 +452,18 @@ func Combination(n, k uint) uint {
return 0
}
nFactorial := Factorial(n)
kFactorial := Factorial(k)
nMinusKFactorial := Factorial(n - k)
// C(n, k) == C(n, n-k), so the loop below runs min(k, n-k) times.
if k > n-k {
k = n - k
}
return nFactorial / (kFactorial * nMinusKFactorial)
// Build C(n-k+i, i) for i = 1..k. Each step is an exact integer, and dividing
// by the gcd first avoids the overflow of computing the factorials of n and k.
result := uint(1)
for i := uint(1); i <= k; i++ {
g := gcd(result, i)
result = result / g * ((n - k + i) / (i / g))
}
return result
}
+31
View File
@@ -2,6 +2,7 @@ package mathutil
import (
"math"
"math/bits"
"testing"
"github.com/duke-git/lancet/v2/internal"
@@ -577,9 +578,39 @@ func TestCombination(t *testing.T) {
{0, 0, 1},
{3, 2, 3},
{4, 2, 6},
{5, 6, 0},
{5, 5, 1},
{5, 0, 1},
{21, 10, 352716},
{34, 17, 2333606220},
}
for _, tt := range tests {
assert.Equal(tt.expected, Combination(tt.n, tt.k))
}
}
func TestCombinationLarge(t *testing.T) {
t.Parallel()
if bits.UintSize < 64 {
t.Skip("the expected values do not fit in a 32-bit uint")
}
assert := internal.NewAssert(t, "TestCombinationLarge")
tests := []struct {
n uint
k uint
expected uint64
}{
{40, 20, 137846528820},
{60, 30, 118264581564861424},
{62, 31, 465428353255261088},
{64, 32, 1832624140942590534},
}
for _, tt := range tests {
assert.Equal(tt.expected, uint64(Combination(tt.n, tt.k)))
}
}
+36 -10
View File
@@ -49,10 +49,6 @@ func (p *Promise[T]) run() {
go func() {
defer func() {
if !p.pending {
return
}
if err := recover(); err != nil {
p.reject(errors.New(fmt.Sprint(err)))
}
@@ -177,7 +173,7 @@ func All[T any](promises []*Promise[T]) *Promise[[]T] {
return New(func(resolve func([]T), reject func(error)) {
valsChan := make(chan tuple[T, int], len(promises))
errsChan := make(chan error, 1)
errsChan := make(chan error, len(promises))
for idx, p := range promises {
idx := idx
@@ -212,8 +208,10 @@ func Race[T any](promises []*Promise[T]) *Promise[T] {
}
return New(func(resolve func(T), reject func(error)) {
valsChan := make(chan T, 1)
errsChan := make(chan error, 1)
// every promise may settle, so each channel holds all of them. A smaller
// buffer leaves the callbacks of the losing promises blocked forever.
valsChan := make(chan T, len(promises))
errsChan := make(chan error, len(promises))
for _, p := range promises {
_ = Then(p, func(data T) T {
@@ -243,7 +241,7 @@ func Any[T any](promises []*Promise[T]) *Promise[T] {
}
return New(func(resolve func(T), reject func(error)) {
valsChan := make(chan T, 1)
valsChan := make(chan T, len(promises))
errsChan := make(chan tuple[error, int], len(promises))
for idx, p := range promises {
@@ -270,10 +268,38 @@ func Any[T any](promises []*Promise[T]) *Promise[T] {
}
errCombo := errs[0]
for _, err := range errs[1:] {
errCombo = internal.JoinError(err)
if len(errs) > 1 {
errCombo = &anyError{errs: errs}
}
reject(errCombo)
})
}
// anyError is the error of Any when every promise is rejected. It keeps errors.Is and
// errors.As working for each member, also on Go versions that cannot traverse Unwrap() []error.
type anyError struct {
errs []error
}
func (e *anyError) Error() string {
return internal.JoinError(e.errs...).Error()
}
func (e *anyError) Is(target error) bool {
for _, err := range e.errs {
if errors.Is(err, target) {
return true
}
}
return false
}
func (e *anyError) As(target any) bool {
for _, err := range e.errs {
if errors.As(err, target) {
return true
}
}
return false
}
+73
View File
@@ -2,6 +2,7 @@ package promise
import (
"errors"
"runtime"
"testing"
"time"
@@ -242,8 +243,44 @@ func TestAny(t *testing.T) {
assert.IsNotNil(err)
})
t.Run("OnlyRejectedJoinsAllErrors", func(_ *testing.T) {
err1 := errors.New("error1")
err2 := &testPathError{msg: "error2"}
p1 := New(func(resolve func(string), reject func(error)) {
reject(err1)
})
p2 := New(func(resolve func(string), reject func(error)) {
reject(err2)
})
_, err := Any([]*Promise[string]{p1, p2}).Await()
assert.IsNotNil(err)
assert.Equal("error1\nerror2", err.Error())
assert.Equal(true, errors.Is(err, err1))
assert.Equal(true, errors.Is(err, err2))
var target *testPathError
assert.Equal(true, errors.As(err, &target))
assert.Equal(true, target == err2)
})
t.Run("SingleRejectedKeepsError", func(_ *testing.T) {
e := errors.New("only")
p1 := New(func(resolve func(string), reject func(error)) {
reject(e)
})
_, err := Any([]*Promise[string]{p1}).Await()
assert.Equal(true, err == e)
})
}
type testPathError struct{ msg string }
func (e *testPathError) Error() string { return e.msg }
func TestRace(t *testing.T) {
assert := internal.NewAssert(t, "TestPromise_Race")
@@ -310,3 +347,39 @@ func TestRace(t *testing.T) {
})
}
func TestRaceAnyAllDoNotLeakGoroutines(t *testing.T) {
// not parallel: it counts the goroutines of the whole test binary
assert := internal.NewAssert(t, "TestRaceAnyAllDoNotLeakGoroutines")
newResolved := func(n int) []*Promise[int] {
ps := make([]*Promise[int], 0, 3)
for i := 0; i < 3; i++ {
ps = append(ps, New(func(resolve func(int), reject func(error)) { resolve(n) }))
}
return ps
}
newRejected := func() []*Promise[int] {
ps := make([]*Promise[int], 0, 3)
for i := 0; i < 3; i++ {
ps = append(ps, New(func(resolve func(int), reject func(error)) { reject(errors.New("e")) }))
}
return ps
}
before := runtime.NumGoroutine()
for i := 0; i < 20; i++ {
_, _ = Race(newResolved(i)).Await()
_, _ = Race(newRejected()).Await()
_, _ = Any(newResolved(i)).Await()
_, _ = All(newRejected()).Await()
}
deadline := time.Now().Add(2 * time.Second)
for runtime.NumGoroutine() > before && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
assert.Equal(true, runtime.NumGoroutine() <= before)
}
+2 -1
View File
@@ -81,7 +81,8 @@ func RetryWithExponentialWithJitterBackoff(interval time.Duration, base uint64,
panic("programming error: retry maxJitter should not be lower to 0")
}
if base%2 == 0 {
// only a power of two can be replaced by a bit shift without changing the multiplier
if base != 0 && base&(base-1) == 0 {
return func(rc *RetryConfig) {
rc.backoffStrategy = &shiftExponentialWithJitter{
interval: interval,
+17
View File
@@ -258,3 +258,20 @@ func TestCancelRetry(t *testing.T) {
assert.IsNotNil(err)
assert.Equal(4, number)
}
func TestRetryExponentialBackoffMultiplier(t *testing.T) {
t.Parallel()
assert := internal.NewAssert(t, "TestRetryExponentialBackoffMultiplier")
for _, base := range []uint64{2, 3, 4, 6, 8, 10} {
rc := &RetryConfig{}
RetryWithExponentialWithJitterBackoff(time.Millisecond, base, 0)(rc)
want := time.Millisecond
for i := 0; i < 3; i++ {
assert.Equal(want, rc.backoffStrategy.CalculateInterval())
want *= time.Duration(base)
}
}
}
+2 -2
View File
@@ -373,7 +373,7 @@ func (s Stream[T]) Max(less func(a, b T) bool) (T, bool) {
}
for i, v := range s.source {
if less(v, max) || i == 0 {
if i == 0 || less(v, max) {
max = v
}
}
@@ -391,7 +391,7 @@ func (s Stream[T]) Min(less func(a, b T) bool) (T, bool) {
}
for i, v := range s.source {
if less(v, min) || i == 0 {
if i == 0 || less(v, min) {
min = v
}
}
+17
View File
@@ -423,3 +423,20 @@ func TestStream_ToMap(t *testing.T) {
assert.EqualValues(expected, m)
}
func TestStream_MaxMinDoNotCompareWithZeroValue(t *testing.T) {
assert := internal.NewAssert(t, "TestStream_MaxMinDoNotCompareWithZeroValue")
type Person struct {
Age int
}
s := FromSlice([]*Person{{Age: 20}, {Age: 10}, {Age: 30}})
max, ok := s.Max(func(a, b *Person) bool { return a.Age > b.Age })
assert.Equal(true, ok)
assert.Equal(30, max.Age)
min, ok := s.Min(func(a, b *Person) bool { return a.Age < b.Age })
assert.Equal(true, ok)
assert.Equal(10, min.Age)
}
+10 -16
View File
@@ -60,25 +60,22 @@ func isDigit(r rune) bool {
return r >= '0' && r <= '9'
}
// isLower checks if a character is lower case ('a' to 'z')
// isLower checks if a character is a lower case letter
func isLower(r rune) bool {
return r >= 'a' && r <= 'z'
return unicode.IsLower(r)
}
// isUpper checks if a character is upper case ('A' to 'Z')
// isUpper checks if a character is an upper case letter
func isUpper(r rune) bool {
return r >= 'A' && r <= 'Z'
return unicode.IsUpper(r)
}
// toLower converts a character 'A' to 'Z' to its lower case
// toLower converts a character to its lower case
func toLower(r rune) rune {
if r >= 'A' && r <= 'Z' {
return r + 32
}
return r
return unicode.ToLower(r)
}
// toLowerAll converts a character 'A' to 'Z' to its lower case
// toLowerAll converts characters to their lower case
func toLowerAll(rs []rune) []rune {
for i := range rs {
rs[i] = toLower(rs[i])
@@ -86,15 +83,12 @@ func toLowerAll(rs []rune) []rune {
return rs
}
// toUpper converts a character 'a' to 'z' to its upper case
// toUpper converts a character to its upper case
func toUpper(r rune) rune {
if r >= 'a' && r <= 'z' {
return r - 32
}
return r
return unicode.ToUpper(r)
}
// toUpperAll converts a character 'a' to 'z' to its upper case
// toUpperAll converts characters to their upper case
func toUpperAll(rs []rune) []rune {
for i := range rs {
rs[i] = toUpper(rs[i])
+12
View File
@@ -23,6 +23,8 @@ func TestCamelCase(t *testing.T) {
{"$foo%", "foo"},
{" $#$Foo 22 bar ", "foo22Bar"},
{"Foo-#1😄$_%^&*(1bar", "foo11Bar"},
{"Привет Мир", "приветМир"},
{"Köln Straße", "kölnStraße"},
}
for _, tt := range tests {
@@ -47,6 +49,8 @@ func TestUpperCamelCase(t *testing.T) {
{"$foo%", "Foo"},
{" $#$Foo 22 bar ", "Foo22Bar"},
{"Foo-#1😄$_%^&*(1bar", "Foo11Bar"},
{"Привет Мир", "ПриветМир"},
{"Köln Straße", "KölnStraße"},
}
for _, tt := range tests {
@@ -99,6 +103,8 @@ func TestKebabCase(t *testing.T) {
{"$foo@Bar", "foo-bar"},
{" $#$Foo 22 bar ", "foo-22-bar"},
{"Foo-#1😄$_%^&*(1bar", "foo-1-1-bar"},
{"Привет Мир", "привет-мир"},
{"Köln Straße", "köln-straße"},
}
for _, tt := range tests {
@@ -127,6 +133,8 @@ func TestUpperKebabCase(t *testing.T) {
{"$foo@Bar", "FOO-BAR"},
{" $#$Foo 22 bar ", "FOO-22-BAR"},
{"Foo-#1😄$_%^&*(1bar", "FOO-1-1-BAR"},
{"Привет Мир", "ПРИВЕТ-МИР"},
{"Köln Straße", "KÖLN-STRAßE"},
}
for _, tt := range tests {
@@ -155,6 +163,8 @@ func TestSnakeCase(t *testing.T) {
{"$foo@Bar", "foo_bar"},
{" $#$Foo 22 bar ", "foo_22_bar"},
{"Foo-#1😄$_%^&*(1bar", "foo_1_1_bar"},
{"Привет Мир", "привет_мир"},
{"Köln Straße", "köln_straße"},
}
for _, tt := range tests {
@@ -183,6 +193,8 @@ func TestUpperSnakeCase(t *testing.T) {
{"$foo@Bar", "FOO_BAR"},
{" $#$Foo 22 bar ", "FOO_22_BAR"},
{"Foo-#1😄$_%^&*(1bar", "FOO_1_1_BAR"},
{"Привет Мир", "ПРИВЕТ_МИР"},
{"Köln Straße", "KÖLN_STRAßE"},
}
for _, tt := range tests {
+1 -1
View File
@@ -252,7 +252,7 @@ func parseProcessInfo(output []byte, pid int) (*ProcessInfo, error) {
Memory: fields[2],
State: fields[3],
User: fields[4],
Cmd: fields[5],
Cmd: strings.Join(fields[5:], " "),
}
}
+19
View File
@@ -2,6 +2,7 @@ package system
import (
"os/exec"
"runtime"
"strings"
"testing"
"time"
@@ -142,3 +143,21 @@ func TestGetProcessInfo(t *testing.T) {
t.Log(processInfo)
}
func TestParseProcessInfo_commandWithSpaces(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("parses ps output")
}
assert := internal.NewAssert(t, "TestParseProcessInfo_commandWithSpaces")
output := []byte(" PID %CPU %MEM STAT USER COMM\n 4242 0.1 0.2 S root Google Chrome Helper\n")
info, err := parseProcessInfo(output, 4242)
assert.IsNil(err)
assert.Equal("Google Chrome Helper", info.Cmd)
assert.Equal("root", info.User)
assert.Equal("S", info.State)
}
+4
View File
@@ -87,6 +87,10 @@ func (e *XError) Error() string {
return msg
}
if msg == "" {
return cause.Error()
}
msg = fmt.Sprintf("%s: %v", msg, cause.Error())
return msg
+12
View File
@@ -115,3 +115,15 @@ func TestXError_Info(t *testing.T) {
assert.Equal(cause, errInfo.Cause)
assert.Equal("high", errInfo.Values["level"])
}
func TestWrap_withoutMessage(t *testing.T) {
t.Parallel()
assert := internal.NewAssert(t, "TestWrap_withoutMessage")
cause := errors.New("wrong password")
assert.Equal("wrong password", Wrap(cause).Error())
assert.Equal("wrong password", Wrap(Wrap(cause)).Error())
assert.Equal("wrong password", New("").Wrap(cause).Error())
}