test: add unit tests for 6 previously-untested packages (R7)

Added pure unit tests for all packages that had 0% coverage. Where pure
logic was entangled with DB calls, extracted testable helpers first.

internal/domain (0% -> 100%):
- TestIsNil, TestCanTransition (all 30 state transitions), TestSentinelErrors,
  TestSignalTransitionsComplete

internal/learning (0% -> 26.2%):
- Refactored processGroup to extract 4 pure helpers: countOutcomes,
  computeConfidence, shouldValidate, shouldQuarantine
- TestWilsonLowerBound (monotonicity, edge cases, sample-size cap)
- TestCountOutcomes, TestComputeConfidence, TestShouldValidate,
  TestShouldQuarantine (table-driven)
- Remaining gap: extractPatterns/processGroup DB calls need make test-db

internal/policy (39% -> 50%):
- Extracted determineRoute from ClassifySignal (pure route logic)
- TestDetermineRoute (7 cases covering global/entity kill-switches, approval)
- Remaining gap: ClassifySignal/computeBlastRadius need DB mock

internal/knowledge (0% -> 14.2%):
- TestContentHash, TestStr, TestStrSlice, TestMapVal, TestToPGArray
- Documented latent bug: toPGArray doesn't escape " or \\ in tags
- Remaining gap: ingest* functions need make test-db

internal/actuator (0% -> 14.7%):
- TestSSHErrorClassString, TestClassifySSHError (11 cases incl. net.Error mock)
- TestParseProcedure, TestSetDefaultSSHTimeout
- Circuit breaker full state-machine test (open/close/reset/per-target)
- Remaining gap: ExecuteProcedure/ProvisionLXC need SSH+DB fixtures

internal/scheduler (0% -> 7.3%):
- TestParsePingLatency (Linux/macOS formats), TestAllowlistedScript
- TestEvaluateSeverity (threshold logic, crit:0 skip, signalKind fallback)
- Remaining gap: checkHTTP/checkTCP need httptest; runCheckPass needs DB

internal/notifier (0% -> 6.4%):
- TestHashToken, TestGenerateApprovalToken (HMAC re-derivation)
- Remaining gap: checkReaction/sendMatrixAlert need httptest; DB funcs need
  make test-db

All tests pass with -race. domain hits its 60% gate at 100%. The remaining
packages need integration tests (make test-db) and/or httptest-based tests
to reach their coverage gates — tracked as follow-up.
This commit is contained in:
2026-07-17 22:54:44 +02:00
parent 463bdacf5c
commit c96c795126
11 changed files with 1282 additions and 35 deletions

View File

@@ -83,27 +83,13 @@ func extractPatterns(ctx context.Context, pool *db.Pool, watermark time.Time) ti
func processGroup(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries,
appliesType, action string, items []sqlcgen.GetFeedbackAfterWatermarkRow) {
successCount := 0
failureCount := 0
for _, f := range items {
switch f.Outcome {
case "success":
successCount++
case "failure", "unexpected":
failureCount++
case "partial":
successCount++ // partial counts as half-success
}
}
successCount, failureCount := countOutcomes(items)
total := successCount + failureCount
if total == 0 {
return
}
// Compute Wilson score lower bound
confidence := wilsonLowerBound(float64(successCount), float64(total), 0.95)
// Cap by sample size: nothing looks confident before 5 samples
confidence = math.Min(confidence, float64(total)/5.0)
confidence := computeConfidence(successCount, failureCount)
// Get or create pattern — first look up existing entity, then upsert.
existing, err := q.GetPattern(ctx, sqlcgen.GetPatternParams{
@@ -148,7 +134,7 @@ func processGroup(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries,
return
}
if pat.EvidenceCount >= 5 && pat.Confidence >= 0.7 && !pat.Quarantined {
if shouldValidate(int(pat.EvidenceCount), float64(pat.Confidence), pat.Quarantined) {
_ = q.UpdatePatternStatus(ctx, sqlcgen.UpdatePatternStatusParams{
EntityID: pat.EntityID,
Status: "validated",
@@ -158,8 +144,7 @@ func processGroup(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries,
"confidence", confidence, "samples", total)
}
// Anomaly check: >10 identical outcomes within 1h
if total > 10 {
if shouldQuarantine(total) {
_ = q.UpdatePatternQuarantine(ctx, sqlcgen.UpdatePatternQuarantineParams{
EntityID: pat.EntityID,
Quarantined: true,
@@ -169,6 +154,45 @@ func processGroup(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries,
}
}
// countOutcomes tallies feedback items into success and failure counts.
// "partial" counts as a half-success (increments success).
func countOutcomes(items []sqlcgen.GetFeedbackAfterWatermarkRow) (success, failure int) {
for _, f := range items {
switch f.Outcome {
case "success":
success++
case "failure", "unexpected":
failure++
case "partial":
success++
}
}
return success, failure
}
// computeConfidence calculates the Wilson score lower bound, capped by
// sample size (nothing looks confident before 5 samples).
func computeConfidence(success, failure int) float64 {
total := success + failure
if total == 0 {
return 0
}
confidence := wilsonLowerBound(float64(success), float64(total), 0.95)
return math.Min(confidence, float64(total)/5.0)
}
// shouldValidate returns true when a pattern has enough evidence and
// confidence to be promoted from "hypothesized" to "validated".
func shouldValidate(evidenceCount int, confidence float64, quarantined bool) bool {
return evidenceCount >= 5 && confidence >= 0.7 && !quarantined
}
// shouldQuarantine returns true when an anomaly burst is detected
// (>10 identical outcomes, indicating a runaway loop rather than organic feedback).
func shouldQuarantine(total int) bool {
return total > 10
}
// wilsonLowerBound computes the Wilson score interval lower bound.
// Conservative estimate of success rate for small sample sizes.
func wilsonLowerBound(success, total, z float64) float64 {

View File

@@ -0,0 +1,211 @@
package learning
import (
"math"
"testing"
"github.com/dtoro/oikos/internal/db/sqlcgen"
)
func TestWilsonLowerBound(t *testing.T) {
tests := []struct {
name string
success float64
total float64
z float64
want float64
// For approximate checks
approx bool
epsilon float64
}{
{"zero total", 5, 0, 1.96, 0, false, 0},
{"zero success", 0, 10, 1.96, 0, true, 1e-10},
{"all success small n", 3, 3, 1.96, 0, true, 0.5},
{"all success large n", 100, 100, 1.96, 0, true, 0.05},
{"half success large n", 50, 100, 1.96, 0.39, true, 0.02},
{"higher z gives lower bound", 8, 10, 3.0, 0, true, 0.5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := wilsonLowerBound(tt.success, tt.total, tt.z)
if tt.approx {
if tt.want > 0 && math.Abs(got-tt.want) > tt.epsilon {
t.Errorf("wilsonLowerBound(%v, %v, %v) = %v, want ~%v (±%v)", tt.success, tt.total, tt.z, got, tt.want, tt.epsilon)
}
if got < 0 {
t.Errorf("wilsonLowerBound returned negative: %v", got)
}
if got > 1 {
t.Errorf("wilsonLowerBound returned >1: %v", got)
}
} else {
if got != tt.want {
t.Errorf("wilsonLowerBound(%v, %v, %v) = %v, want %v", tt.success, tt.total, tt.z, got, tt.want)
}
}
})
}
}
func TestWilsonLowerBoundMonotonic(t *testing.T) {
prev := 0.0
for s := 0.0; s <= 20; s++ {
got := wilsonLowerBound(s, 20, 1.96)
if got < prev-1e-9 {
t.Errorf("not monotonically increasing: s=%v got=%v prev=%v", s, got, prev)
}
prev = got
}
}
func TestCountOutcomes(t *testing.T) {
tests := []struct {
name string
items []sqlcgen.GetFeedbackAfterWatermarkRow
wantSuccess int
wantFailure int
}{
{
"empty",
nil,
0, 0,
},
{
"all success",
[]sqlcgen.GetFeedbackAfterWatermarkRow{
{Outcome: "success"},
{Outcome: "success"},
},
2, 0,
},
{
"all failure",
[]sqlcgen.GetFeedbackAfterWatermarkRow{
{Outcome: "failure"},
{Outcome: "unexpected"},
},
0, 2,
},
{
"mixed including partial",
[]sqlcgen.GetFeedbackAfterWatermarkRow{
{Outcome: "success"},
{Outcome: "failure"},
{Outcome: "partial"},
{Outcome: "unexpected"},
},
2, 2,
},
{
"unknown outcome ignored",
[]sqlcgen.GetFeedbackAfterWatermarkRow{
{Outcome: "success"},
{Outcome: "bogus"},
},
1, 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s, f := countOutcomes(tt.items)
if s != tt.wantSuccess || f != tt.wantFailure {
t.Errorf("countOutcomes() = (%d, %d), want (%d, %d)", s, f, tt.wantSuccess, tt.wantFailure)
}
})
}
}
func TestComputeConfidence(t *testing.T) {
tests := []struct {
name string
success int
failure int
wantMax float64
wantMin float64
}{
{"zero total", 0, 0, 0, 0},
{"one success no failures", 1, 0, 0.2, 0},
{"five success no failures", 5, 0, 1.0, 0.3},
{"ten success no failures", 10, 0, 1.0, 0.5},
{"half success", 5, 5, 0.5, 0.2},
{"all failures", 0, 10, 0.01, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := computeConfidence(tt.success, tt.failure)
if got < 0 || got > 1 {
t.Errorf("confidence out of [0,1]: %v", got)
}
if got > tt.wantMax+0.01 {
t.Errorf("confidence too high: got %v, max ~%v", got, tt.wantMax)
}
if tt.wantMin > 0 && got < tt.wantMin-0.1 {
t.Errorf("confidence too low: got %v, min ~%v", got, tt.wantMin)
}
})
}
}
func TestComputeConfidenceSampleSizeCap(t *testing.T) {
got := computeConfidence(1, 0)
if got > 0.2+1e-9 {
t.Errorf("sample size cap not applied: 1 sample should cap at 1/5=0.2, got %v", got)
}
got = computeConfidence(4, 0)
if got > 0.8+1e-9 {
t.Errorf("sample size cap not applied: 4 samples should cap at 4/5=0.8, got %v", got)
}
}
func TestShouldValidate(t *testing.T) {
tests := []struct {
name string
evidenceCount int
confidence float64
quarantined bool
want bool
}{
{"enough evidence and confidence", 5, 0.7, false, true},
{"high evidence high confidence", 10, 0.9, false, true},
{"not enough evidence", 4, 0.9, false, false},
{"not enough confidence", 5, 0.69, false, false},
{"quarantined blocks validation", 10, 0.9, true, false},
{"exactly at threshold", 5, 0.7, false, true},
{"zero evidence", 0, 0.9, false, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shouldValidate(tt.evidenceCount, tt.confidence, tt.quarantined)
if got != tt.want {
t.Errorf("shouldValidate(%d, %v, %v) = %v, want %v", tt.evidenceCount, tt.confidence, tt.quarantined, got, tt.want)
}
})
}
}
func TestShouldQuarantine(t *testing.T) {
tests := []struct {
name string
total int
want bool
}{
{"zero", 0, false},
{"small", 5, false},
{"at threshold", 10, false},
{"over threshold", 11, true},
{"large burst", 50, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shouldQuarantine(tt.total)
if got != tt.want {
t.Errorf("shouldQuarantine(%d) = %v, want %v", tt.total, got, tt.want)
}
})
}
}