From 2470f1ac3dd2c4e56962c03a325c47b5f287f943 Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 5 Apr 2026 08:08:28 +0100 Subject: [PATCH] feat(proxy): add log tests, fix nil config panic, complete test triads - Add log package tests (AccessLog and ShareLog Good/Bad/Ugly triads) - Fix nil pointer panic in pool.NewStrategyFactory when config is nil - Add Worker Hashrate Good/Bad/Ugly test triad - Add ConfigWatcher Start Bad test (nonexistent path) - Add FailoverStrategy CurrentPools Bad/Ugly, EnabledPools Good/Bad/Ugly, and NewStrategyFactory Good/Bad/Ugly test triads - Improve doc comments on Stats, StatsSummary, Workers, WorkerRecord with AX-compliant usage examples Co-Authored-By: Virgil --- configwatcher_test.go | 30 ++++ log/impl_test.go | 341 ++++++++++++++++++++++++++++++++++++++++++ pool/impl.go | 6 +- pool/impl_test.go | 141 +++++++++++++++++ stats.go | 18 ++- worker.go | 15 +- worker_test.go | 80 ++++++++++ 7 files changed, 619 insertions(+), 12 deletions(-) create mode 100644 log/impl_test.go diff --git a/configwatcher_test.go b/configwatcher_test.go index 76518f5..9e0a6c6 100644 --- a/configwatcher_test.go +++ b/configwatcher_test.go @@ -66,6 +66,36 @@ func TestConfigWatcher_Start_Good(t *testing.T) { } } +// TestConfigWatcher_Start_Bad verifies a watcher with a nonexistent path does not panic +// and does not call the onChange callback. +// +// watcher := proxy.NewConfigWatcher("/nonexistent/config.json", func(cfg *proxy.Config) { +// // never called +// }) +// watcher.Start() +// watcher.Stop() +func TestConfigWatcher_Start_Bad(t *testing.T) { + called := make(chan struct{}, 1) + watcher := NewConfigWatcher("/nonexistent/path/config.json", func(*Config) { + select { + case called <- struct{}{}: + default: + } + }) + if watcher == nil { + t.Fatal("expected watcher even for a nonexistent path") + } + watcher.Start() + defer watcher.Stop() + + select { + case <-called: + t.Fatal("expected no callback for nonexistent config file") + case <-time.After(2 * time.Second): + // expected: no update fired + } +} + func TestConfigWatcher_Start_Ugly(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.json") diff --git a/log/impl_test.go b/log/impl_test.go new file mode 100644 index 0000000..1feabd3 --- /dev/null +++ b/log/impl_test.go @@ -0,0 +1,341 @@ +package log + +import ( + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "dappco.re/go/proxy" +) + +// TestAccessLog_OnLogin_Good verifies a CONNECT line is written with the expected columns. +// +// al := log.NewAccessLog("/tmp/test-access.log") +// al.OnLogin(proxy.Event{Miner: miner}) // writes "CONNECT 10.0.0.1 WALLET XMRig/6.21.0" +func TestAccessLog_OnLogin_Good(t *testing.T) { + path := filepath.Join(t.TempDir(), "access.log") + al := NewAccessLog(path) + defer al.Close() + + miner := newTestMiner(t) + al.OnLogin(proxy.Event{Miner: miner}) + al.Close() + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("expected log file to exist: %v", err) + } + line := strings.TrimSpace(string(data)) + if !strings.Contains(line, "CONNECT") { + t.Fatalf("expected CONNECT in log line, got %q", line) + } +} + +// TestAccessLog_OnLogin_Bad verifies a nil miner event does not panic or write anything. +// +// al := log.NewAccessLog("/tmp/test-access.log") +// al.OnLogin(proxy.Event{Miner: nil}) // no-op +func TestAccessLog_OnLogin_Bad(t *testing.T) { + path := filepath.Join(t.TempDir(), "access.log") + al := NewAccessLog(path) + defer al.Close() + + al.OnLogin(proxy.Event{Miner: nil}) + al.Close() + + if _, err := os.Stat(path); err == nil { + data, _ := os.ReadFile(path) + if len(data) > 0 { + t.Fatalf("expected no output for nil miner, got %q", string(data)) + } + } +} + +// TestAccessLog_OnLogin_Ugly verifies a nil AccessLog does not panic. +// +// var al *log.AccessLog +// al.OnLogin(proxy.Event{Miner: miner}) // no-op, no panic +func TestAccessLog_OnLogin_Ugly(t *testing.T) { + var al *AccessLog + miner := newTestMiner(t) + al.OnLogin(proxy.Event{Miner: miner}) +} + +// TestAccessLog_OnClose_Good verifies a CLOSE line includes rx and tx byte counts. +// +// al := log.NewAccessLog("/tmp/test-access.log") +// al.OnClose(proxy.Event{Miner: miner}) // writes "CLOSE rx=0 tx=0" +func TestAccessLog_OnClose_Good(t *testing.T) { + path := filepath.Join(t.TempDir(), "access.log") + al := NewAccessLog(path) + defer al.Close() + + miner := newTestMiner(t) + al.OnClose(proxy.Event{Miner: miner}) + al.Close() + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("expected log file to exist: %v", err) + } + line := strings.TrimSpace(string(data)) + if !strings.Contains(line, "CLOSE") { + t.Fatalf("expected CLOSE in log line, got %q", line) + } + if !strings.Contains(line, "rx=") { + t.Fatalf("expected rx= in log line, got %q", line) + } + if !strings.Contains(line, "tx=") { + t.Fatalf("expected tx= in log line, got %q", line) + } +} + +// TestAccessLog_OnClose_Bad verifies a nil miner close event produces no output. +// +// al := log.NewAccessLog("/tmp/test-access.log") +// al.OnClose(proxy.Event{Miner: nil}) // no-op +func TestAccessLog_OnClose_Bad(t *testing.T) { + path := filepath.Join(t.TempDir(), "access.log") + al := NewAccessLog(path) + defer al.Close() + + al.OnClose(proxy.Event{Miner: nil}) + al.Close() + + if _, err := os.Stat(path); err == nil { + data, _ := os.ReadFile(path) + if len(data) > 0 { + t.Fatalf("expected no output for nil miner, got %q", string(data)) + } + } +} + +// TestAccessLog_OnClose_Ugly verifies close on an empty-path log is a no-op. +// +// al := log.NewAccessLog("") +// al.OnClose(proxy.Event{Miner: miner}) // no-op, empty path +func TestAccessLog_OnClose_Ugly(t *testing.T) { + al := NewAccessLog("") + defer al.Close() + + miner := newTestMiner(t) + al.OnClose(proxy.Event{Miner: miner}) +} + +// TestShareLog_OnAccept_Good verifies an ACCEPT line is written with diff and latency. +// +// sl := log.NewShareLog("/tmp/test-shares.log") +// sl.OnAccept(proxy.Event{Miner: miner, Diff: 100000, Latency: 82}) +func TestShareLog_OnAccept_Good(t *testing.T) { + path := filepath.Join(t.TempDir(), "shares.log") + sl := NewShareLog(path) + defer sl.Close() + + miner := newTestMiner(t) + sl.OnAccept(proxy.Event{Miner: miner, Diff: 100000, Latency: 82}) + sl.Close() + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("expected log file to exist: %v", err) + } + line := strings.TrimSpace(string(data)) + if !strings.Contains(line, "ACCEPT") { + t.Fatalf("expected ACCEPT in log line, got %q", line) + } + if !strings.Contains(line, "diff=100000") { + t.Fatalf("expected diff=100000 in log line, got %q", line) + } + if !strings.Contains(line, "latency=82ms") { + t.Fatalf("expected latency=82ms in log line, got %q", line) + } +} + +// TestShareLog_OnAccept_Bad verifies a nil miner accept event produces no output. +// +// sl := log.NewShareLog("/tmp/test-shares.log") +// sl.OnAccept(proxy.Event{Miner: nil}) // no-op +func TestShareLog_OnAccept_Bad(t *testing.T) { + path := filepath.Join(t.TempDir(), "shares.log") + sl := NewShareLog(path) + defer sl.Close() + + sl.OnAccept(proxy.Event{Miner: nil, Diff: 100000}) + sl.Close() + + if _, err := os.Stat(path); err == nil { + data, _ := os.ReadFile(path) + if len(data) > 0 { + t.Fatalf("expected no output for nil miner, got %q", string(data)) + } + } +} + +// TestShareLog_OnAccept_Ugly verifies a nil ShareLog does not panic. +// +// var sl *log.ShareLog +// sl.OnAccept(proxy.Event{Miner: miner}) // no-op, no panic +func TestShareLog_OnAccept_Ugly(t *testing.T) { + var sl *ShareLog + miner := newTestMiner(t) + sl.OnAccept(proxy.Event{Miner: miner, Diff: 100000}) +} + +// TestShareLog_OnReject_Good verifies a REJECT line is written with the rejection reason. +// +// sl := log.NewShareLog("/tmp/test-shares.log") +// sl.OnReject(proxy.Event{Miner: miner, Error: "Low difficulty share"}) +func TestShareLog_OnReject_Good(t *testing.T) { + path := filepath.Join(t.TempDir(), "shares.log") + sl := NewShareLog(path) + defer sl.Close() + + miner := newTestMiner(t) + sl.OnReject(proxy.Event{Miner: miner, Error: "Low difficulty share"}) + sl.Close() + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("expected log file to exist: %v", err) + } + line := strings.TrimSpace(string(data)) + if !strings.Contains(line, "REJECT") { + t.Fatalf("expected REJECT in log line, got %q", line) + } + if !strings.Contains(line, "Low difficulty share") { + t.Fatalf("expected rejection reason in log line, got %q", line) + } +} + +// TestShareLog_OnReject_Bad verifies a nil miner reject event produces no output. +// +// sl := log.NewShareLog("/tmp/test-shares.log") +// sl.OnReject(proxy.Event{Miner: nil}) // no-op +func TestShareLog_OnReject_Bad(t *testing.T) { + path := filepath.Join(t.TempDir(), "shares.log") + sl := NewShareLog(path) + defer sl.Close() + + sl.OnReject(proxy.Event{Miner: nil, Error: "Low difficulty share"}) + sl.Close() + + if _, err := os.Stat(path); err == nil { + data, _ := os.ReadFile(path) + if len(data) > 0 { + t.Fatalf("expected no output for nil miner, got %q", string(data)) + } + } +} + +// TestShareLog_OnReject_Ugly verifies an empty-path ShareLog silently discards the reject line. +// +// sl := log.NewShareLog("") +// sl.OnReject(proxy.Event{Miner: miner, Error: "reason"}) // no-op, empty path +func TestShareLog_OnReject_Ugly(t *testing.T) { + sl := NewShareLog("") + defer sl.Close() + + miner := newTestMiner(t) + sl.OnReject(proxy.Event{Miner: miner, Error: "reason"}) +} + +// TestAccessLog_Close_Good verifies Close releases the file handle and is safe to call twice. +// +// al := log.NewAccessLog("/tmp/test-access.log") +// al.OnLogin(proxy.Event{Miner: miner}) +// al.Close() +// al.Close() // double close is safe +func TestAccessLog_Close_Good(t *testing.T) { + path := filepath.Join(t.TempDir(), "access.log") + al := NewAccessLog(path) + + miner := newTestMiner(t) + al.OnLogin(proxy.Event{Miner: miner}) + al.Close() + al.Close() +} + +// TestAccessLog_Close_Bad verifies Close on a nil AccessLog does not panic. +// +// var al *log.AccessLog +// al.Close() // no-op, no panic +func TestAccessLog_Close_Bad(t *testing.T) { + var al *AccessLog + al.Close() +} + +// TestAccessLog_Close_Ugly verifies Close on a never-opened log does not panic. +// +// al := log.NewAccessLog("/nonexistent/dir/access.log") +// al.Close() // no file was ever opened +func TestAccessLog_Close_Ugly(t *testing.T) { + al := NewAccessLog("/nonexistent/dir/access.log") + al.Close() +} + +// TestShareLog_Close_Good verifies Close releases the file handle and is safe to call twice. +// +// sl := log.NewShareLog("/tmp/test-shares.log") +// sl.OnAccept(proxy.Event{Miner: miner, Diff: 1000}) +// sl.Close() +// sl.Close() // double close is safe +func TestShareLog_Close_Good(t *testing.T) { + path := filepath.Join(t.TempDir(), "shares.log") + sl := NewShareLog(path) + + miner := newTestMiner(t) + sl.OnAccept(proxy.Event{Miner: miner, Diff: 1000}) + sl.Close() + sl.Close() +} + +// TestShareLog_Close_Bad verifies Close on a nil ShareLog does not panic. +// +// var sl *log.ShareLog +// sl.Close() // no-op, no panic +func TestShareLog_Close_Bad(t *testing.T) { + var sl *ShareLog + sl.Close() +} + +// TestShareLog_Close_Ugly verifies Close on a never-opened log does not panic. +// +// sl := log.NewShareLog("/nonexistent/dir/shares.log") +// sl.Close() // no file was ever opened +func TestShareLog_Close_Ugly(t *testing.T) { + sl := NewShareLog("/nonexistent/dir/shares.log") + sl.Close() +} + +// newTestMiner creates a minimal miner for log testing using a net.Pipe connection. +func newTestMiner(t *testing.T) *proxy.Miner { + t.Helper() + client, server := net.Pipe() + t.Cleanup(func() { + _ = client.Close() + _ = server.Close() + }) + miner := proxy.NewMiner(client, 3333, nil) + miner.SetID(1) + return miner +} + +// pipeAddr satisfies the net.Addr interface for pipe-based test connections. +type pipeAddr struct{} + +func (a pipeAddr) Network() string { return "pipe" } +func (a pipeAddr) String() string { return "pipe" } + +// pipeConn wraps an os.Pipe as a net.Conn for tests that need a closeable socket. +type pipeConn struct { + *os.File +} + +func (p *pipeConn) RemoteAddr() net.Addr { return pipeAddr{} } +func (p *pipeConn) LocalAddr() net.Addr { return pipeAddr{} } +func (p *pipeConn) SetDeadline(_ time.Time) error { return nil } +func (p *pipeConn) SetReadDeadline(_ time.Time) error { return nil } +func (p *pipeConn) SetWriteDeadline(_ time.Time) error { return nil } diff --git a/pool/impl.go b/pool/impl.go index b29fe98..977c1aa 100644 --- a/pool/impl.go +++ b/pool/impl.go @@ -22,7 +22,11 @@ import ( // strategy := factory(listener) func NewStrategyFactory(config *proxy.Config) StrategyFactory { return func(listener StratumListener) Strategy { - return NewFailoverStrategy(config.Pools, listener, config) + var pools []proxy.PoolConfig + if config != nil { + pools = config.Pools + } + return NewFailoverStrategy(pools, listener, config) } } diff --git a/pool/impl_test.go b/pool/impl_test.go index 9abbb66..3c951a7 100644 --- a/pool/impl_test.go +++ b/pool/impl_test.go @@ -6,6 +6,10 @@ import ( "dappco.re/go/proxy" ) +// TestFailoverStrategy_CurrentPools_Good verifies that currentPools follows the live config. +// +// strategy := pool.NewFailoverStrategy(cfg.Pools, nil, cfg) +// strategy.currentPools() // returns cfg.Pools func TestFailoverStrategy_CurrentPools_Good(t *testing.T) { cfg := &proxy.Config{ Mode: "nicehash", @@ -25,3 +29,140 @@ func TestFailoverStrategy_CurrentPools_Good(t *testing.T) { t.Fatalf("expected current pools to follow config reload, got %+v", got) } } + +// TestFailoverStrategy_CurrentPools_Bad verifies that a nil strategy returns an empty pool list. +// +// var strategy *pool.FailoverStrategy +// strategy.currentPools() // nil +func TestFailoverStrategy_CurrentPools_Bad(t *testing.T) { + var strategy *FailoverStrategy + pools := strategy.currentPools() + if pools != nil { + t.Fatalf("expected nil pools from nil strategy, got %+v", pools) + } +} + +// TestFailoverStrategy_CurrentPools_Ugly verifies that a strategy with a nil config +// falls back to the pools passed at construction time. +// +// strategy := pool.NewFailoverStrategy(initialPools, nil, nil) +// strategy.currentPools() // returns initialPools +func TestFailoverStrategy_CurrentPools_Ugly(t *testing.T) { + initialPools := []proxy.PoolConfig{ + {URL: "fallback.example:3333", Enabled: true}, + {URL: "fallback.example:4444", Enabled: false}, + } + strategy := NewFailoverStrategy(initialPools, nil, nil) + + got := strategy.currentPools() + if len(got) != 2 { + t.Fatalf("expected 2 pools from constructor fallback, got %d", len(got)) + } + if got[0].URL != "fallback.example:3333" { + t.Fatalf("expected constructor pool URL, got %q", got[0].URL) + } +} + +// TestFailoverStrategy_EnabledPools_Good verifies that only enabled pools are selected. +// +// enabled := pool.enabledPools(pools) // filters to enabled-only +func TestFailoverStrategy_EnabledPools_Good(t *testing.T) { + pools := []proxy.PoolConfig{ + {URL: "active.example:3333", Enabled: true}, + {URL: "disabled.example:3333", Enabled: false}, + {URL: "active2.example:3333", Enabled: true}, + } + got := enabledPools(pools) + if len(got) != 2 { + t.Fatalf("expected 2 enabled pools, got %d", len(got)) + } + if got[0].URL != "active.example:3333" || got[1].URL != "active2.example:3333" { + t.Fatalf("expected only enabled pool URLs, got %+v", got) + } +} + +// TestFailoverStrategy_EnabledPools_Bad verifies that an empty pool list returns empty. +// +// pool.enabledPools(nil) // empty +func TestFailoverStrategy_EnabledPools_Bad(t *testing.T) { + got := enabledPools(nil) + if len(got) != 0 { + t.Fatalf("expected 0 pools from nil input, got %d", len(got)) + } +} + +// TestFailoverStrategy_EnabledPools_Ugly verifies that all-disabled pools return empty. +// +// pool.enabledPools([]proxy.PoolConfig{{Enabled: false}}) // empty +func TestFailoverStrategy_EnabledPools_Ugly(t *testing.T) { + pools := []proxy.PoolConfig{ + {URL: "a.example:3333", Enabled: false}, + {URL: "b.example:3333", Enabled: false}, + } + got := enabledPools(pools) + if len(got) != 0 { + t.Fatalf("expected 0 enabled pools when all disabled, got %d", len(got)) + } +} + +// TestNewStrategyFactory_Good verifies the factory creates a strategy connected to the config. +// +// factory := pool.NewStrategyFactory(cfg) +// strategy := factory(listener) // creates FailoverStrategy +func TestNewStrategyFactory_Good(t *testing.T) { + cfg := &proxy.Config{ + Mode: "nicehash", + Workers: proxy.WorkersByRigID, + Bind: []proxy.BindAddr{{Host: "127.0.0.1", Port: 3333}}, + Pools: []proxy.PoolConfig{{URL: "pool.example:3333", Enabled: true}}, + } + factory := NewStrategyFactory(cfg) + if factory == nil { + t.Fatal("expected a non-nil factory") + } + strategy := factory(nil) + if strategy == nil { + t.Fatal("expected a non-nil strategy from factory") + } + if strategy.IsActive() { + t.Fatal("expected new strategy to be inactive before connecting") + } +} + +// TestNewStrategyFactory_Bad verifies a factory created with nil config does not panic. +// +// factory := pool.NewStrategyFactory(nil) +// strategy := factory(nil) +func TestNewStrategyFactory_Bad(t *testing.T) { + factory := NewStrategyFactory(nil) + strategy := factory(nil) + if strategy == nil { + t.Fatal("expected a non-nil strategy even from nil config") + } +} + +// TestNewStrategyFactory_Ugly verifies the factory forwards the correct pool list to the strategy. +// +// cfg.Pools = append(cfg.Pools, proxy.PoolConfig{URL: "added.example:3333", Enabled: true}) +// strategy := factory(nil) +// // strategy sees the updated pools via the shared config pointer +func TestNewStrategyFactory_Ugly(t *testing.T) { + cfg := &proxy.Config{ + Mode: "nicehash", + Workers: proxy.WorkersByRigID, + Bind: []proxy.BindAddr{{Host: "127.0.0.1", Port: 3333}}, + Pools: []proxy.PoolConfig{{URL: "pool.example:3333", Enabled: true}}, + } + factory := NewStrategyFactory(cfg) + cfg.Pools = append(cfg.Pools, proxy.PoolConfig{URL: "added.example:3333", Enabled: true}) + + strategy := factory(nil) + fs, ok := strategy.(*FailoverStrategy) + if !ok { + t.Fatal("expected FailoverStrategy") + } + pools := fs.currentPools() + if len(pools) != 2 { + t.Fatalf("expected 2 pools after config update, got %d", len(pools)) + } +} diff --git a/stats.go b/stats.go index 8e70eaf..b163d1e 100644 --- a/stats.go +++ b/stats.go @@ -6,11 +6,14 @@ import ( "time" ) -// stats := proxy.NewStats() -// bus.Subscribe(proxy.EventAccept, stats.OnAccept) -// bus.Subscribe(proxy.EventReject, stats.OnReject) -// stats.Tick() -// _ = stats.Summary() +// Stats tracks global proxy metrics. Hot-path counters are atomic. Hashrate windows +// use a ring buffer per window size, advanced by Tick(). +// +// stats := proxy.NewStats() +// bus.Subscribe(proxy.EventAccept, stats.OnAccept) +// bus.Subscribe(proxy.EventReject, stats.OnReject) +// stats.Tick() +// summary := stats.Summary() type Stats struct { accepted atomic.Uint64 rejected atomic.Uint64 @@ -45,7 +48,10 @@ type tickWindow struct { size int // window size in seconds = len(buckets) } -// summary := proxy.NewStats().Summary() +// StatsSummary is the serialisable snapshot returned by Summary(). +// +// summary := proxy.NewStats().Summary() +// _ = summary.Hashrate[0] // 60-second window H/s type StatsSummary struct { Accepted uint64 `json:"accepted"` Rejected uint64 `json:"rejected"` diff --git a/worker.go b/worker.go index 3d09910..5374afd 100644 --- a/worker.go +++ b/worker.go @@ -5,9 +5,12 @@ import ( "time" ) -// workers := proxy.NewWorkers(proxy.WorkersByRigID, bus) -// workers.OnLogin(proxy.Event{Miner: &proxy.Miner{rigID: "rig-alpha", user: "WALLET", ip: "10.0.0.1"}}) -// _ = workers.List() +// Workers maintains per-worker aggregate stats. Workers are identified by name, +// derived from the miner's login fields per WorkersMode. +// +// workers := proxy.NewWorkers(proxy.WorkersByRigID, bus) +// workers.OnLogin(proxy.Event{Miner: miner}) +// records := workers.List() type Workers struct { mode WorkersMode entries []WorkerRecord // ordered by first-seen (stable) @@ -17,8 +20,10 @@ type Workers struct { mu sync.RWMutex } -// record := proxy.WorkerRecord{Name: "rig-alpha"} -// _ = record.Hashrate(60) +// WorkerRecord is the per-identity aggregate with rolling hashrate windows. +// +// record := proxy.WorkerRecord{Name: "rig-alpha", Accepted: 10, Hashes: 500000} +// hr60 := record.Hashrate(60) // H/s over the last 60 seconds type WorkerRecord struct { Name string LastIP string diff --git a/worker_test.go b/worker_test.go index 7f7edde..e3149ce 100644 --- a/worker_test.go +++ b/worker_test.go @@ -48,6 +48,86 @@ func TestWorker_NewWorkers_Ugly(t *testing.T) { } } +// TestWorker_Hashrate_Good verifies that recording an accepted share produces a nonzero +// hashrate reading from the 60-second window. +// +// record := proxy.WorkerRecord{} +// record.Hashrate(60) // > 0.0 after an accepted share +func TestWorker_Hashrate_Good(t *testing.T) { + bus := NewEventBus() + workers := NewWorkers(WorkersByUser, bus) + + miner := &Miner{id: 100, user: "hashtest", ip: "10.0.0.10"} + bus.Dispatch(Event{Type: EventLogin, Miner: miner}) + bus.Dispatch(Event{Type: EventAccept, Miner: miner, Diff: 50000}) + + records := workers.List() + if len(records) != 1 { + t.Fatalf("expected one worker record, got %d", len(records)) + } + hr := records[0].Hashrate(60) + if hr <= 0 { + t.Fatalf("expected nonzero hashrate for 60-second window after accept, got %f", hr) + } +} + +// TestWorker_Hashrate_Bad verifies that an invalid window size returns 0. +// +// record := proxy.WorkerRecord{} +// record.Hashrate(999) // 0.0 (unsupported window) +func TestWorker_Hashrate_Bad(t *testing.T) { + bus := NewEventBus() + workers := NewWorkers(WorkersByUser, bus) + + miner := &Miner{id: 101, user: "hashtest-bad", ip: "10.0.0.11"} + bus.Dispatch(Event{Type: EventLogin, Miner: miner}) + bus.Dispatch(Event{Type: EventAccept, Miner: miner, Diff: 50000}) + + records := workers.List() + if len(records) != 1 { + t.Fatalf("expected one worker record, got %d", len(records)) + } + hr := records[0].Hashrate(999) + if hr != 0 { + t.Fatalf("expected zero hashrate for unsupported window, got %f", hr) + } + hrZero := records[0].Hashrate(0) + if hrZero != 0 { + t.Fatalf("expected zero hashrate for zero window, got %f", hrZero) + } + hrNeg := records[0].Hashrate(-1) + if hrNeg != 0 { + t.Fatalf("expected zero hashrate for negative window, got %f", hrNeg) + } +} + +// TestWorker_Hashrate_Ugly verifies that calling Hashrate on a nil record returns 0 +// and that a worker with no accepts also returns 0. +// +// var record *proxy.WorkerRecord +// record.Hashrate(60) // 0.0 +func TestWorker_Hashrate_Ugly(t *testing.T) { + var nilRecord *WorkerRecord + if hr := nilRecord.Hashrate(60); hr != 0 { + t.Fatalf("expected zero hashrate for nil record, got %f", hr) + } + + bus := NewEventBus() + workers := NewWorkers(WorkersByUser, bus) + + miner := &Miner{id: 102, user: "hashtest-ugly", ip: "10.0.0.12"} + bus.Dispatch(Event{Type: EventLogin, Miner: miner}) + + records := workers.List() + if len(records) != 1 { + t.Fatalf("expected one worker record, got %d", len(records)) + } + hr := records[0].Hashrate(60) + if hr != 0 { + t.Fatalf("expected zero hashrate for worker with no accepts, got %f", hr) + } +} + func TestWorker_CustomDiffOrdering_Good(t *testing.T) { cfg := &Config{ Mode: "nicehash",