diff --git a/pkg/io/sigil/crypto_sigil_test.go b/pkg/io/sigil/crypto_sigil_test.go
new file mode 100644
index 0000000..c87a368
--- /dev/null
+++ b/pkg/io/sigil/crypto_sigil_test.go
@@ -0,0 +1,536 @@
+package sigil
+
+import (
+ "bytes"
+ "crypto/rand"
+ "errors"
+ "io"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// ── XORObfuscator ──────────────────────────────────────────────────
+
+func TestXORObfuscator_Good_RoundTrip(t *testing.T) {
+ ob := &XORObfuscator{}
+ data := []byte("the axioms are in the weights")
+ entropy := []byte("deterministic-nonce-24bytes!")
+
+ obfuscated := ob.Obfuscate(data, entropy)
+ assert.NotEqual(t, data, obfuscated)
+ assert.Len(t, obfuscated, len(data))
+
+ restored := ob.Deobfuscate(obfuscated, entropy)
+ assert.Equal(t, data, restored)
+}
+
+func TestXORObfuscator_Good_DifferentEntropyDifferentOutput(t *testing.T) {
+ ob := &XORObfuscator{}
+ data := []byte("same plaintext")
+
+ out1 := ob.Obfuscate(data, []byte("entropy-a"))
+ out2 := ob.Obfuscate(data, []byte("entropy-b"))
+ assert.NotEqual(t, out1, out2)
+}
+
+func TestXORObfuscator_Good_Deterministic(t *testing.T) {
+ ob := &XORObfuscator{}
+ data := []byte("reproducible")
+ entropy := []byte("fixed-seed")
+
+ out1 := ob.Obfuscate(data, entropy)
+ out2 := ob.Obfuscate(data, entropy)
+ assert.Equal(t, out1, out2)
+}
+
+func TestXORObfuscator_Good_LargeData(t *testing.T) {
+ ob := &XORObfuscator{}
+ // Larger than one SHA-256 block (32 bytes) to test multi-block key stream.
+ data := make([]byte, 256)
+ for i := range data {
+ data[i] = byte(i)
+ }
+ entropy := []byte("test-entropy")
+
+ obfuscated := ob.Obfuscate(data, entropy)
+ restored := ob.Deobfuscate(obfuscated, entropy)
+ assert.Equal(t, data, restored)
+}
+
+func TestXORObfuscator_Good_EmptyData(t *testing.T) {
+ ob := &XORObfuscator{}
+ result := ob.Obfuscate([]byte{}, []byte("entropy"))
+ assert.Equal(t, []byte{}, result)
+
+ result = ob.Deobfuscate([]byte{}, []byte("entropy"))
+ assert.Equal(t, []byte{}, result)
+}
+
+func TestXORObfuscator_Good_SymmetricProperty(t *testing.T) {
+ ob := &XORObfuscator{}
+ data := []byte("XOR is its own inverse")
+ entropy := []byte("nonce")
+
+ // XOR is symmetric: Obfuscate(Obfuscate(x)) == x
+ double := ob.Obfuscate(ob.Obfuscate(data, entropy), entropy)
+ assert.Equal(t, data, double)
+}
+
+// ── ShuffleMaskObfuscator ──────────────────────────────────────────
+
+func TestShuffleMaskObfuscator_Good_RoundTrip(t *testing.T) {
+ ob := &ShuffleMaskObfuscator{}
+ data := []byte("shuffle and mask protect patterns")
+ entropy := []byte("deterministic-entropy")
+
+ obfuscated := ob.Obfuscate(data, entropy)
+ assert.NotEqual(t, data, obfuscated)
+ assert.Len(t, obfuscated, len(data))
+
+ restored := ob.Deobfuscate(obfuscated, entropy)
+ assert.Equal(t, data, restored)
+}
+
+func TestShuffleMaskObfuscator_Good_DifferentEntropy(t *testing.T) {
+ ob := &ShuffleMaskObfuscator{}
+ data := []byte("same data")
+
+ out1 := ob.Obfuscate(data, []byte("entropy-1"))
+ out2 := ob.Obfuscate(data, []byte("entropy-2"))
+ assert.NotEqual(t, out1, out2)
+}
+
+func TestShuffleMaskObfuscator_Good_Deterministic(t *testing.T) {
+ ob := &ShuffleMaskObfuscator{}
+ data := []byte("reproducible shuffle")
+ entropy := []byte("fixed")
+
+ out1 := ob.Obfuscate(data, entropy)
+ out2 := ob.Obfuscate(data, entropy)
+ assert.Equal(t, out1, out2)
+}
+
+func TestShuffleMaskObfuscator_Good_LargeData(t *testing.T) {
+ ob := &ShuffleMaskObfuscator{}
+ data := make([]byte, 512)
+ for i := range data {
+ data[i] = byte(i % 256)
+ }
+ entropy := []byte("large-data-test")
+
+ obfuscated := ob.Obfuscate(data, entropy)
+ restored := ob.Deobfuscate(obfuscated, entropy)
+ assert.Equal(t, data, restored)
+}
+
+func TestShuffleMaskObfuscator_Good_EmptyData(t *testing.T) {
+ ob := &ShuffleMaskObfuscator{}
+ result := ob.Obfuscate([]byte{}, []byte("entropy"))
+ assert.Equal(t, []byte{}, result)
+
+ result = ob.Deobfuscate([]byte{}, []byte("entropy"))
+ assert.Equal(t, []byte{}, result)
+}
+
+func TestShuffleMaskObfuscator_Good_SingleByte(t *testing.T) {
+ ob := &ShuffleMaskObfuscator{}
+ data := []byte{0x42}
+ entropy := []byte("single")
+
+ obfuscated := ob.Obfuscate(data, entropy)
+ restored := ob.Deobfuscate(obfuscated, entropy)
+ assert.Equal(t, data, restored)
+}
+
+// ── NewChaChaPolySigil ─────────────────────────────────────────────
+
+func TestNewChaChaPolySigil_Good(t *testing.T) {
+ key := make([]byte, 32)
+ _, _ = rand.Read(key)
+
+ s, err := NewChaChaPolySigil(key)
+ require.NoError(t, err)
+ assert.NotNil(t, s)
+ assert.Equal(t, key, s.Key)
+ assert.NotNil(t, s.Obfuscator)
+}
+
+func TestNewChaChaPolySigil_Good_KeyIsCopied(t *testing.T) {
+ key := make([]byte, 32)
+ _, _ = rand.Read(key)
+ original := make([]byte, 32)
+ copy(original, key)
+
+ s, err := NewChaChaPolySigil(key)
+ require.NoError(t, err)
+
+ // Mutating the original key should not affect the sigil.
+ key[0] ^= 0xFF
+ assert.Equal(t, original, s.Key)
+}
+
+func TestNewChaChaPolySigil_Bad_ShortKey(t *testing.T) {
+ _, err := NewChaChaPolySigil([]byte("too short"))
+ assert.ErrorIs(t, err, ErrInvalidKey)
+}
+
+func TestNewChaChaPolySigil_Bad_LongKey(t *testing.T) {
+ _, err := NewChaChaPolySigil(make([]byte, 64))
+ assert.ErrorIs(t, err, ErrInvalidKey)
+}
+
+func TestNewChaChaPolySigil_Bad_EmptyKey(t *testing.T) {
+ _, err := NewChaChaPolySigil(nil)
+ assert.ErrorIs(t, err, ErrInvalidKey)
+}
+
+// ── NewChaChaPolySigilWithObfuscator ───────────────────────────────
+
+func TestNewChaChaPolySigilWithObfuscator_Good(t *testing.T) {
+ key := make([]byte, 32)
+ _, _ = rand.Read(key)
+
+ ob := &ShuffleMaskObfuscator{}
+ s, err := NewChaChaPolySigilWithObfuscator(key, ob)
+ require.NoError(t, err)
+ assert.Equal(t, ob, s.Obfuscator)
+}
+
+func TestNewChaChaPolySigilWithObfuscator_Good_NilObfuscator(t *testing.T) {
+ key := make([]byte, 32)
+ _, _ = rand.Read(key)
+
+ s, err := NewChaChaPolySigilWithObfuscator(key, nil)
+ require.NoError(t, err)
+ // Falls back to default XORObfuscator.
+ assert.IsType(t, &XORObfuscator{}, s.Obfuscator)
+}
+
+func TestNewChaChaPolySigilWithObfuscator_Bad_InvalidKey(t *testing.T) {
+ _, err := NewChaChaPolySigilWithObfuscator([]byte("bad"), &XORObfuscator{})
+ assert.ErrorIs(t, err, ErrInvalidKey)
+}
+
+// ── ChaChaPolySigil In/Out (encrypt/decrypt) ───────────────────────
+
+func TestChaChaPolySigil_Good_RoundTrip(t *testing.T) {
+ key := make([]byte, 32)
+ _, _ = rand.Read(key)
+
+ s, err := NewChaChaPolySigil(key)
+ require.NoError(t, err)
+
+ plaintext := []byte("consciousness does not merely avoid causing harm")
+ ciphertext, err := s.In(plaintext)
+ require.NoError(t, err)
+ assert.NotEqual(t, plaintext, ciphertext)
+ assert.Greater(t, len(ciphertext), len(plaintext)) // nonce + tag overhead
+
+ decrypted, err := s.Out(ciphertext)
+ require.NoError(t, err)
+ assert.Equal(t, plaintext, decrypted)
+}
+
+func TestChaChaPolySigil_Good_WithShuffleMask(t *testing.T) {
+ key := make([]byte, 32)
+ _, _ = rand.Read(key)
+
+ s, err := NewChaChaPolySigilWithObfuscator(key, &ShuffleMaskObfuscator{})
+ require.NoError(t, err)
+
+ plaintext := []byte("shuffle mask pre-obfuscation layer")
+ ciphertext, err := s.In(plaintext)
+ require.NoError(t, err)
+
+ decrypted, err := s.Out(ciphertext)
+ require.NoError(t, err)
+ assert.Equal(t, plaintext, decrypted)
+}
+
+func TestChaChaPolySigil_Good_NilData(t *testing.T) {
+ key := make([]byte, 32)
+ _, _ = rand.Read(key)
+
+ s, err := NewChaChaPolySigil(key)
+ require.NoError(t, err)
+
+ enc, err := s.In(nil)
+ require.NoError(t, err)
+ assert.Nil(t, enc)
+
+ dec, err := s.Out(nil)
+ require.NoError(t, err)
+ assert.Nil(t, dec)
+}
+
+func TestChaChaPolySigil_Good_EmptyPlaintext(t *testing.T) {
+ key := make([]byte, 32)
+ _, _ = rand.Read(key)
+
+ s, err := NewChaChaPolySigil(key)
+ require.NoError(t, err)
+
+ ciphertext, err := s.In([]byte{})
+ require.NoError(t, err)
+ assert.NotEmpty(t, ciphertext) // Has nonce + tag even for empty plaintext.
+
+ decrypted, err := s.Out(ciphertext)
+ require.NoError(t, err)
+ assert.Equal(t, []byte{}, decrypted)
+}
+
+func TestChaChaPolySigil_Good_DifferentCiphertextsPerCall(t *testing.T) {
+ key := make([]byte, 32)
+ _, _ = rand.Read(key)
+
+ s, err := NewChaChaPolySigil(key)
+ require.NoError(t, err)
+
+ plaintext := []byte("same input")
+ ct1, _ := s.In(plaintext)
+ ct2, _ := s.In(plaintext)
+
+ // Different nonces → different ciphertexts.
+ assert.NotEqual(t, ct1, ct2)
+}
+
+func TestChaChaPolySigil_Bad_NoKey(t *testing.T) {
+ s := &ChaChaPolySigil{}
+
+ _, err := s.In([]byte("data"))
+ assert.ErrorIs(t, err, ErrNoKeyConfigured)
+
+ _, err = s.Out([]byte("data"))
+ assert.ErrorIs(t, err, ErrNoKeyConfigured)
+}
+
+func TestChaChaPolySigil_Bad_WrongKey(t *testing.T) {
+ key1 := make([]byte, 32)
+ key2 := make([]byte, 32)
+ _, _ = rand.Read(key1)
+ _, _ = rand.Read(key2)
+
+ s1, _ := NewChaChaPolySigil(key1)
+ s2, _ := NewChaChaPolySigil(key2)
+
+ ciphertext, err := s1.In([]byte("secret"))
+ require.NoError(t, err)
+
+ _, err = s2.Out(ciphertext)
+ assert.ErrorIs(t, err, ErrDecryptionFailed)
+}
+
+func TestChaChaPolySigil_Bad_TruncatedCiphertext(t *testing.T) {
+ key := make([]byte, 32)
+ _, _ = rand.Read(key)
+
+ s, _ := NewChaChaPolySigil(key)
+ _, err := s.Out([]byte("too short"))
+ assert.ErrorIs(t, err, ErrCiphertextTooShort)
+}
+
+func TestChaChaPolySigil_Bad_TamperedCiphertext(t *testing.T) {
+ key := make([]byte, 32)
+ _, _ = rand.Read(key)
+
+ s, _ := NewChaChaPolySigil(key)
+ ciphertext, _ := s.In([]byte("authentic data"))
+
+ // Flip a bit in the ciphertext body (after nonce).
+ ciphertext[30] ^= 0xFF
+
+ _, err := s.Out(ciphertext)
+ assert.ErrorIs(t, err, ErrDecryptionFailed)
+}
+
+// failReader returns an error on read — for testing nonce generation failure.
+type failReader struct{}
+
+func (f *failReader) Read([]byte) (int, error) {
+ return 0, errors.New("entropy source failed")
+}
+
+func TestChaChaPolySigil_Bad_RandReaderFailure(t *testing.T) {
+ key := make([]byte, 32)
+ _, _ = rand.Read(key)
+
+ s, _ := NewChaChaPolySigil(key)
+ s.randReader = &failReader{}
+
+ _, err := s.In([]byte("data"))
+ assert.Error(t, err)
+}
+
+// ── ChaChaPolySigil without obfuscator ─────────────────────────────
+
+func TestChaChaPolySigil_Good_NoObfuscator(t *testing.T) {
+ key := make([]byte, 32)
+ _, _ = rand.Read(key)
+
+ s, _ := NewChaChaPolySigil(key)
+ s.Obfuscator = nil // Disable pre-obfuscation.
+
+ plaintext := []byte("raw encryption without pre-obfuscation")
+ ciphertext, err := s.In(plaintext)
+ require.NoError(t, err)
+
+ decrypted, err := s.Out(ciphertext)
+ require.NoError(t, err)
+ assert.Equal(t, plaintext, decrypted)
+}
+
+// ── GetNonceFromCiphertext ─────────────────────────────────────────
+
+func TestGetNonceFromCiphertext_Good(t *testing.T) {
+ key := make([]byte, 32)
+ _, _ = rand.Read(key)
+
+ s, _ := NewChaChaPolySigil(key)
+ ciphertext, _ := s.In([]byte("nonce extraction test"))
+
+ nonce, err := GetNonceFromCiphertext(ciphertext)
+ require.NoError(t, err)
+ assert.Len(t, nonce, 24) // XChaCha20 nonce is 24 bytes.
+
+ // Nonce should match the prefix of the ciphertext.
+ assert.Equal(t, ciphertext[:24], nonce)
+}
+
+func TestGetNonceFromCiphertext_Good_NonceCopied(t *testing.T) {
+ key := make([]byte, 32)
+ _, _ = rand.Read(key)
+
+ s, _ := NewChaChaPolySigil(key)
+ ciphertext, _ := s.In([]byte("data"))
+
+ nonce, _ := GetNonceFromCiphertext(ciphertext)
+ original := make([]byte, len(nonce))
+ copy(original, nonce)
+
+ // Mutating the nonce should not affect the ciphertext.
+ nonce[0] ^= 0xFF
+ assert.Equal(t, original, ciphertext[:24])
+}
+
+func TestGetNonceFromCiphertext_Bad_TooShort(t *testing.T) {
+ _, err := GetNonceFromCiphertext([]byte("short"))
+ assert.ErrorIs(t, err, ErrCiphertextTooShort)
+}
+
+func TestGetNonceFromCiphertext_Bad_Empty(t *testing.T) {
+ _, err := GetNonceFromCiphertext(nil)
+ assert.ErrorIs(t, err, ErrCiphertextTooShort)
+}
+
+// ── ChaChaPolySigil in Transmute pipeline ──────────────────────────
+
+func TestChaChaPolySigil_Good_InTransmutePipeline(t *testing.T) {
+ key := make([]byte, 32)
+ _, _ = rand.Read(key)
+
+ s, _ := NewChaChaPolySigil(key)
+ hexSigil, _ := NewSigil("hex")
+
+ chain := []Sigil{s, hexSigil}
+ plaintext := []byte("encrypt then hex encode")
+
+ encoded, err := Transmute(plaintext, chain)
+ require.NoError(t, err)
+
+ // Result should be hex-encoded ciphertext.
+ assert.True(t, isHex(encoded))
+
+ decoded, err := Untransmute(encoded, chain)
+ require.NoError(t, err)
+ assert.Equal(t, plaintext, decoded)
+}
+
+func isHex(data []byte) bool {
+ for _, b := range data {
+ if !((b >= '0' && b <= '9') || (b >= 'a' && b <= 'f')) {
+ return false
+ }
+ }
+ return len(data) > 0
+}
+
+// ── Transmute error propagation ────────────────────────────────────
+
+type failSigil struct{}
+
+func (f *failSigil) In([]byte) ([]byte, error) { return nil, errors.New("fail in") }
+func (f *failSigil) Out([]byte) ([]byte, error) { return nil, errors.New("fail out") }
+
+func TestTransmute_Bad_ErrorPropagation(t *testing.T) {
+ _, err := Transmute([]byte("data"), []Sigil{&failSigil{}})
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "fail in")
+}
+
+func TestUntransmute_Bad_ErrorPropagation(t *testing.T) {
+ _, err := Untransmute([]byte("data"), []Sigil{&failSigil{}})
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "fail out")
+}
+
+// ── GzipSigil with custom writer (edge case) ──────────────────────
+
+func TestGzipSigil_Good_CustomWriter(t *testing.T) {
+ var buf bytes.Buffer
+ s := &GzipSigil{writer: &buf}
+
+ // With custom writer, compressed data goes to buf, returned bytes will be empty
+ // because the internal buffer 'b' is unused when s.writer is set.
+ _, err := s.In([]byte("test data"))
+ require.NoError(t, err)
+ assert.Greater(t, buf.Len(), 0)
+}
+
+// ── deriveKeyStream edge: exactly 32 bytes ─────────────────────────
+
+func TestDeriveKeyStream_Good_ExactBlockSize(t *testing.T) {
+ ob := &XORObfuscator{}
+ data := make([]byte, 32) // Exactly one SHA-256 block.
+ for i := range data {
+ data[i] = byte(i)
+ }
+ entropy := []byte("block-boundary")
+
+ obfuscated := ob.Obfuscate(data, entropy)
+ restored := ob.Deobfuscate(obfuscated, entropy)
+ assert.Equal(t, data, restored)
+}
+
+// ── io.Reader fallback in In ───────────────────────────────────────
+
+func TestChaChaPolySigil_Good_NilRandReader(t *testing.T) {
+ key := make([]byte, 32)
+ _, _ = rand.Read(key)
+
+ s, _ := NewChaChaPolySigil(key)
+ s.randReader = nil // Should fall back to crypto/rand.Reader.
+
+ ciphertext, err := s.In([]byte("fallback reader"))
+ require.NoError(t, err)
+
+ decrypted, err := s.Out(ciphertext)
+ require.NoError(t, err)
+ assert.Equal(t, []byte("fallback reader"), decrypted)
+}
+
+// limitReader returns exactly N bytes then EOF — for deterministic tests.
+type limitReader struct {
+ data []byte
+ pos int
+}
+
+func (l *limitReader) Read(p []byte) (int, error) {
+ if l.pos >= len(l.data) {
+ return 0, io.EOF
+ }
+ n := copy(p, l.data[l.pos:])
+ l.pos += n
+ return n, nil
+}
diff --git a/pkg/lab/config_test.go b/pkg/lab/config_test.go
new file mode 100644
index 0000000..b4cc809
--- /dev/null
+++ b/pkg/lab/config_test.go
@@ -0,0 +1,129 @@
+package lab
+
+import (
+ "os"
+ "testing"
+)
+
+// ── LoadConfig defaults ────────────────────────────────────────────
+
+func TestLoadConfig_Good_Defaults(t *testing.T) {
+ cfg := LoadConfig()
+
+ if cfg.Addr != ":8080" {
+ t.Fatalf("expected :8080, got %s", cfg.Addr)
+ }
+ if cfg.PrometheusURL != "http://prometheus:9090" {
+ t.Fatalf("unexpected PrometheusURL: %s", cfg.PrometheusURL)
+ }
+ if cfg.PrometheusInterval != 15 {
+ t.Fatalf("expected 15, got %d", cfg.PrometheusInterval)
+ }
+ if cfg.ForgeURL != "https://forge.lthn.io" {
+ t.Fatalf("unexpected ForgeURL: %s", cfg.ForgeURL)
+ }
+ if cfg.ForgeInterval != 60 {
+ t.Fatalf("expected 60, got %d", cfg.ForgeInterval)
+ }
+ if cfg.HFAuthor != "lthn" {
+ t.Fatalf("expected lthn, got %s", cfg.HFAuthor)
+ }
+ if cfg.HFInterval != 300 {
+ t.Fatalf("expected 300, got %d", cfg.HFInterval)
+ }
+ if cfg.TrainingDataDir != "/data/training" {
+ t.Fatalf("unexpected TrainingDataDir: %s", cfg.TrainingDataDir)
+ }
+ if cfg.InfluxDB != "training" {
+ t.Fatalf("expected training, got %s", cfg.InfluxDB)
+ }
+}
+
+// ── env override ───────────────────────────────────────────────────
+
+func TestLoadConfig_Good_EnvOverride(t *testing.T) {
+ os.Setenv("ADDR", ":9090")
+ os.Setenv("FORGE_URL", "https://forge.lthn.ai")
+ os.Setenv("HF_AUTHOR", "snider")
+ defer func() {
+ os.Unsetenv("ADDR")
+ os.Unsetenv("FORGE_URL")
+ os.Unsetenv("HF_AUTHOR")
+ }()
+
+ cfg := LoadConfig()
+ if cfg.Addr != ":9090" {
+ t.Fatalf("expected :9090, got %s", cfg.Addr)
+ }
+ if cfg.ForgeURL != "https://forge.lthn.ai" {
+ t.Fatalf("expected forge.lthn.ai, got %s", cfg.ForgeURL)
+ }
+ if cfg.HFAuthor != "snider" {
+ t.Fatalf("expected snider, got %s", cfg.HFAuthor)
+ }
+}
+
+// ── envInt ─────────────────────────────────────────────────────────
+
+func TestLoadConfig_Good_IntEnvOverride(t *testing.T) {
+ os.Setenv("PROMETHEUS_INTERVAL", "30")
+ defer os.Unsetenv("PROMETHEUS_INTERVAL")
+
+ cfg := LoadConfig()
+ if cfg.PrometheusInterval != 30 {
+ t.Fatalf("expected 30, got %d", cfg.PrometheusInterval)
+ }
+}
+
+func TestLoadConfig_Bad_InvalidIntFallsBack(t *testing.T) {
+ os.Setenv("PROMETHEUS_INTERVAL", "not-a-number")
+ defer os.Unsetenv("PROMETHEUS_INTERVAL")
+
+ cfg := LoadConfig()
+ if cfg.PrometheusInterval != 15 {
+ t.Fatalf("expected fallback 15, got %d", cfg.PrometheusInterval)
+ }
+}
+
+// ── env / envInt helpers directly ──────────────────────────────────
+
+func TestEnv_Good(t *testing.T) {
+ os.Setenv("TEST_LAB_KEY", "hello")
+ defer os.Unsetenv("TEST_LAB_KEY")
+
+ if got := env("TEST_LAB_KEY", "default"); got != "hello" {
+ t.Fatalf("expected hello, got %s", got)
+ }
+}
+
+func TestEnv_Good_Fallback(t *testing.T) {
+ os.Unsetenv("TEST_LAB_MISSING")
+ if got := env("TEST_LAB_MISSING", "fallback"); got != "fallback" {
+ t.Fatalf("expected fallback, got %s", got)
+ }
+}
+
+func TestEnvInt_Good(t *testing.T) {
+ os.Setenv("TEST_LAB_INT", "42")
+ defer os.Unsetenv("TEST_LAB_INT")
+
+ if got := envInt("TEST_LAB_INT", 0); got != 42 {
+ t.Fatalf("expected 42, got %d", got)
+ }
+}
+
+func TestEnvInt_Bad_Fallback(t *testing.T) {
+ os.Unsetenv("TEST_LAB_INT_MISSING")
+ if got := envInt("TEST_LAB_INT_MISSING", 99); got != 99 {
+ t.Fatalf("expected 99, got %d", got)
+ }
+}
+
+func TestEnvInt_Bad_InvalidString(t *testing.T) {
+ os.Setenv("TEST_LAB_INT_BAD", "xyz")
+ defer os.Unsetenv("TEST_LAB_INT_BAD")
+
+ if got := envInt("TEST_LAB_INT_BAD", 7); got != 7 {
+ t.Fatalf("expected fallback 7, got %d", got)
+ }
+}
diff --git a/pkg/lab/store_test.go b/pkg/lab/store_test.go
new file mode 100644
index 0000000..6a37646
--- /dev/null
+++ b/pkg/lab/store_test.go
@@ -0,0 +1,391 @@
+package lab
+
+import (
+ "errors"
+ "testing"
+ "time"
+)
+
+// ── NewStore ────────────────────────────────────────────────────────
+
+func TestNewStore_Good(t *testing.T) {
+ s := NewStore()
+ if s == nil {
+ t.Fatal("NewStore returned nil")
+ }
+ if s.subs == nil {
+ t.Fatal("subs map not initialised")
+ }
+ if s.errors == nil {
+ t.Fatal("errors map not initialised")
+ }
+}
+
+// ── Subscribe / Unsubscribe ────────────────────────────────────────
+
+func TestSubscribe_Good(t *testing.T) {
+ s := NewStore()
+ ch := s.Subscribe()
+ if ch == nil {
+ t.Fatal("Subscribe returned nil channel")
+ }
+
+ s.subMu.Lock()
+ _, ok := s.subs[ch]
+ s.subMu.Unlock()
+ if !ok {
+ t.Fatal("subscriber not registered")
+ }
+}
+
+func TestUnsubscribe_Good(t *testing.T) {
+ s := NewStore()
+ ch := s.Subscribe()
+ s.Unsubscribe(ch)
+
+ s.subMu.Lock()
+ _, ok := s.subs[ch]
+ s.subMu.Unlock()
+ if ok {
+ t.Fatal("subscriber not removed after Unsubscribe")
+ }
+}
+
+func TestUnsubscribe_Bad_NeverSubscribed(t *testing.T) {
+ s := NewStore()
+ ch := make(chan struct{}, 1)
+ // Should not panic.
+ s.Unsubscribe(ch)
+}
+
+// ── Notify ─────────────────────────────────────────────────────────
+
+func TestNotify_Good_SubscriberReceivesSignal(t *testing.T) {
+ s := NewStore()
+ ch := s.Subscribe()
+ defer s.Unsubscribe(ch)
+
+ s.SetMachines([]Machine{{Name: "test"}})
+
+ select {
+ case <-ch:
+ // good
+ case <-time.After(100 * time.Millisecond):
+ t.Fatal("subscriber did not receive notification")
+ }
+}
+
+func TestNotify_Good_NonBlockingWhenFull(t *testing.T) {
+ s := NewStore()
+ ch := s.Subscribe()
+ defer s.Unsubscribe(ch)
+
+ // Fill the buffer.
+ ch <- struct{}{}
+
+ // Should not block.
+ s.SetMachines([]Machine{{Name: "a"}})
+ s.SetMachines([]Machine{{Name: "b"}})
+}
+
+func TestNotify_Good_MultipleSubscribers(t *testing.T) {
+ s := NewStore()
+ ch1 := s.Subscribe()
+ ch2 := s.Subscribe()
+ defer s.Unsubscribe(ch1)
+ defer s.Unsubscribe(ch2)
+
+ s.SetAgents(AgentSummary{Available: true})
+
+ for _, ch := range []chan struct{}{ch1, ch2} {
+ select {
+ case <-ch:
+ case <-time.After(100 * time.Millisecond):
+ t.Fatal("subscriber missed notification")
+ }
+ }
+}
+
+// ── SetMachines / Overview ─────────────────────────────────────────
+
+func TestSetMachines_Good(t *testing.T) {
+ s := NewStore()
+ machines := []Machine{{Name: "noc", Host: "77.42.42.205"}, {Name: "de1", Host: "116.202.82.115"}}
+ s.SetMachines(machines)
+
+ ov := s.Overview()
+ if len(ov.Machines) != 2 {
+ t.Fatalf("expected 2 machines, got %d", len(ov.Machines))
+ }
+ if ov.Machines[0].Name != "noc" {
+ t.Fatalf("expected noc, got %s", ov.Machines[0].Name)
+ }
+}
+
+func TestOverview_Good_ContainersMergedIntoFirstMachine(t *testing.T) {
+ s := NewStore()
+ s.SetMachines([]Machine{{Name: "primary"}, {Name: "secondary"}})
+ s.SetContainers([]Container{{Name: "forgejo", Status: "running"}})
+
+ ov := s.Overview()
+ if len(ov.Machines[0].Containers) != 1 {
+ t.Fatal("containers not merged into first machine")
+ }
+ if ov.Machines[0].Containers[0].Name != "forgejo" {
+ t.Fatalf("unexpected container name: %s", ov.Machines[0].Containers[0].Name)
+ }
+ if len(ov.Machines[1].Containers) != 0 {
+ t.Fatal("containers leaked into second machine")
+ }
+}
+
+func TestOverview_Good_EmptyMachinesNoContainerPanic(t *testing.T) {
+ s := NewStore()
+ s.SetContainers([]Container{{Name: "c1"}})
+
+ // No machines set — should not panic.
+ ov := s.Overview()
+ if len(ov.Machines) != 0 {
+ t.Fatal("expected zero machines")
+ }
+}
+
+func TestOverview_Good_ErrorsCopied(t *testing.T) {
+ s := NewStore()
+ s.SetError("prometheus", errors.New("connection refused"))
+
+ ov := s.Overview()
+ if ov.Errors["prometheus"] != "connection refused" {
+ t.Fatal("error not in overview")
+ }
+
+ // Mutating the copy should not affect the store.
+ ov.Errors["prometheus"] = "hacked"
+ ov2 := s.Overview()
+ if ov2.Errors["prometheus"] != "connection refused" {
+ t.Fatal("overview errors map is not a copy")
+ }
+}
+
+// ── SetAgents / GetAgents ──────────────────────────────────────────
+
+func TestAgents_Good(t *testing.T) {
+ s := NewStore()
+ s.SetAgents(AgentSummary{Available: true, RegisteredTotal: 3, QueuePending: 1})
+
+ got := s.GetAgents()
+ if !got.Available {
+ t.Fatal("expected Available=true")
+ }
+ if got.RegisteredTotal != 3 {
+ t.Fatalf("expected 3, got %d", got.RegisteredTotal)
+ }
+}
+
+// ── SetTraining / GetTraining ──────────────────────────────────────
+
+func TestTraining_Good(t *testing.T) {
+ s := NewStore()
+ s.SetTraining(TrainingSummary{GoldGenerated: 404, GoldTarget: 15000, GoldPercent: 2.69})
+
+ got := s.GetTraining()
+ if got.GoldGenerated != 404 {
+ t.Fatalf("expected 404, got %d", got.GoldGenerated)
+ }
+}
+
+// ── SetModels / GetModels ──────────────────────────────────────────
+
+func TestModels_Good(t *testing.T) {
+ s := NewStore()
+ s.SetModels([]HFModel{{ModelID: "lthn/lem-gemma3-1b", Downloads: 42}})
+
+ got := s.GetModels()
+ if len(got) != 1 {
+ t.Fatal("expected 1 model")
+ }
+ if got[0].Downloads != 42 {
+ t.Fatalf("expected 42 downloads, got %d", got[0].Downloads)
+ }
+}
+
+// ── SetCommits ─────────────────────────────────────────────────────
+
+func TestCommits_Good(t *testing.T) {
+ s := NewStore()
+ s.SetCommits([]Commit{{SHA: "abc123", Message: "feat: test coverage", Author: "virgil"}})
+
+ ov := s.Overview()
+ if len(ov.Commits) != 1 {
+ t.Fatal("expected 1 commit")
+ }
+ if ov.Commits[0].Author != "virgil" {
+ t.Fatalf("expected virgil, got %s", ov.Commits[0].Author)
+ }
+}
+
+// ── SetContainers / GetContainers ──────────────────────────────────
+
+func TestContainers_Good(t *testing.T) {
+ s := NewStore()
+ s.SetContainers([]Container{{Name: "traefik", Status: "running"}, {Name: "forgejo", Status: "running"}})
+
+ got := s.GetContainers()
+ if len(got) != 2 {
+ t.Fatal("expected 2 containers")
+ }
+}
+
+// ── SetError / GetErrors ───────────────────────────────────────────
+
+func TestSetError_Good_SetAndClear(t *testing.T) {
+ s := NewStore()
+ s.SetError("hf", errors.New("rate limited"))
+
+ errs := s.GetErrors()
+ if errs["hf"] != "rate limited" {
+ t.Fatal("error not stored")
+ }
+
+ // Clear by passing nil.
+ s.SetError("hf", nil)
+ errs = s.GetErrors()
+ if _, ok := errs["hf"]; ok {
+ t.Fatal("error not cleared")
+ }
+}
+
+func TestGetErrors_Good_ReturnsCopy(t *testing.T) {
+ s := NewStore()
+ s.SetError("forge", errors.New("timeout"))
+
+ errs := s.GetErrors()
+ errs["forge"] = "tampered"
+
+ fresh := s.GetErrors()
+ if fresh["forge"] != "timeout" {
+ t.Fatal("GetErrors did not return a copy")
+ }
+}
+
+// ── SetServices / GetServices ──────────────────────────────────────
+
+func TestServices_Good(t *testing.T) {
+ s := NewStore()
+ s.SetServices([]Service{{Name: "Forgejo", URL: "https://forge.lthn.ai", Status: "ok"}})
+
+ got := s.GetServices()
+ if len(got) != 1 {
+ t.Fatal("expected 1 service")
+ }
+ if got[0].Name != "Forgejo" {
+ t.Fatalf("expected Forgejo, got %s", got[0].Name)
+ }
+}
+
+// ── SetBenchmarks / GetBenchmarks ──────────────────────────────────
+
+func TestBenchmarks_Good(t *testing.T) {
+ s := NewStore()
+ s.SetBenchmarks(BenchmarkData{
+ Runs: []BenchmarkRun{{RunID: "run-1", Model: "gemma3-4b", Type: "training"}},
+ })
+
+ got := s.GetBenchmarks()
+ if len(got.Runs) != 1 {
+ t.Fatal("expected 1 benchmark run")
+ }
+}
+
+// ── SetGoldenSet / GetGoldenSet ────────────────────────────────────
+
+func TestGoldenSet_Good(t *testing.T) {
+ s := NewStore()
+ s.SetGoldenSet(GoldenSetSummary{Available: true, TotalExamples: 15000, TargetTotal: 15000, CompletionPct: 100})
+
+ got := s.GetGoldenSet()
+ if !got.Available {
+ t.Fatal("expected Available=true")
+ }
+ if got.TotalExamples != 15000 {
+ t.Fatalf("expected 15000, got %d", got.TotalExamples)
+ }
+}
+
+// ── SetTrainingRuns / GetTrainingRuns ───────────────────────────────
+
+func TestTrainingRuns_Good(t *testing.T) {
+ s := NewStore()
+ s.SetTrainingRuns([]TrainingRunStatus{
+ {Model: "gemma3-4b", RunID: "r1", Status: "training", Iteration: 100, TotalIters: 300},
+ })
+
+ got := s.GetTrainingRuns()
+ if len(got) != 1 {
+ t.Fatal("expected 1 training run")
+ }
+ if got[0].Iteration != 100 {
+ t.Fatalf("expected iter 100, got %d", got[0].Iteration)
+ }
+}
+
+// ── SetDataset / GetDataset ────────────────────────────────────────
+
+func TestDataset_Good(t *testing.T) {
+ s := NewStore()
+ s.SetDataset(DatasetSummary{
+ Available: true,
+ Tables: []DatasetTable{{Name: "golden_set", Rows: 15000}},
+ })
+
+ got := s.GetDataset()
+ if !got.Available {
+ t.Fatal("expected Available=true")
+ }
+ if len(got.Tables) != 1 {
+ t.Fatal("expected 1 table")
+ }
+}
+
+// ── Concurrent access (race detector) ──────────────────────────────
+
+func TestConcurrentAccess_Good(t *testing.T) {
+ s := NewStore()
+ done := make(chan struct{})
+
+ // Writer goroutine.
+ go func() {
+ for i := range 100 {
+ s.SetMachines([]Machine{{Name: "noc"}})
+ s.SetAgents(AgentSummary{Available: true})
+ s.SetTraining(TrainingSummary{GoldGenerated: i})
+ s.SetModels([]HFModel{{ModelID: "m1"}})
+ s.SetCommits([]Commit{{SHA: "abc"}})
+ s.SetContainers([]Container{{Name: "c1"}})
+ s.SetError("test", errors.New("e"))
+ s.SetServices([]Service{{Name: "s1"}})
+ s.SetBenchmarks(BenchmarkData{})
+ s.SetGoldenSet(GoldenSetSummary{})
+ s.SetTrainingRuns([]TrainingRunStatus{})
+ s.SetDataset(DatasetSummary{})
+ }
+ close(done)
+ }()
+
+ // Reader goroutine.
+ for range 100 {
+ _ = s.Overview()
+ _ = s.GetModels()
+ _ = s.GetTraining()
+ _ = s.GetAgents()
+ _ = s.GetContainers()
+ _ = s.GetServices()
+ _ = s.GetBenchmarks()
+ _ = s.GetGoldenSet()
+ _ = s.GetTrainingRuns()
+ _ = s.GetDataset()
+ _ = s.GetErrors()
+ }
+
+ <-done
+}
diff --git a/pkg/plugin/installer_test.go b/pkg/plugin/installer_test.go
index b8afcf4..7c32a75 100644
--- a/pkg/plugin/installer_test.go
+++ b/pkg/plugin/installer_test.go
@@ -1,11 +1,110 @@
package plugin
import (
+ "context"
"testing"
+ "forge.lthn.ai/core/go/pkg/io"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
+// ── NewInstaller ───────────────────────────────────────────────────
+
+func TestNewInstaller_Good(t *testing.T) {
+ m := io.NewMockMedium()
+ reg := NewRegistry(m, "/plugins")
+ inst := NewInstaller(m, reg)
+
+ assert.NotNil(t, inst)
+ assert.Equal(t, m, inst.medium)
+ assert.Equal(t, reg, inst.registry)
+}
+
+// ── Install error paths ────────────────────────────────────────────
+
+func TestInstall_Bad_InvalidSource(t *testing.T) {
+ m := io.NewMockMedium()
+ reg := NewRegistry(m, "/plugins")
+ inst := NewInstaller(m, reg)
+
+ err := inst.Install(context.Background(), "bad-source")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid source")
+}
+
+func TestInstall_Bad_AlreadyInstalled(t *testing.T) {
+ m := io.NewMockMedium()
+ reg := NewRegistry(m, "/plugins")
+ _ = reg.Add(&PluginConfig{Name: "my-plugin", Version: "1.0.0"})
+
+ inst := NewInstaller(m, reg)
+ err := inst.Install(context.Background(), "org/my-plugin")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "already installed")
+}
+
+// ── Remove ─────────────────────────────────────────────────────────
+
+func TestRemove_Good(t *testing.T) {
+ m := io.NewMockMedium()
+ reg := NewRegistry(m, "/plugins")
+ _ = reg.Add(&PluginConfig{Name: "removable", Version: "1.0.0"})
+
+ // Create plugin directory.
+ _ = m.EnsureDir("/plugins/removable")
+ _ = m.Write("/plugins/removable/plugin.json", `{"name":"removable"}`)
+
+ inst := NewInstaller(m, reg)
+ err := inst.Remove("removable")
+ require.NoError(t, err)
+
+ // Plugin removed from registry.
+ _, ok := reg.Get("removable")
+ assert.False(t, ok)
+
+ // Directory cleaned up.
+ assert.False(t, m.Exists("/plugins/removable"))
+}
+
+func TestRemove_Good_DirAlreadyGone(t *testing.T) {
+ m := io.NewMockMedium()
+ reg := NewRegistry(m, "/plugins")
+ _ = reg.Add(&PluginConfig{Name: "ghost", Version: "1.0.0"})
+ // No directory exists — should still succeed.
+
+ inst := NewInstaller(m, reg)
+ err := inst.Remove("ghost")
+ require.NoError(t, err)
+
+ _, ok := reg.Get("ghost")
+ assert.False(t, ok)
+}
+
+func TestRemove_Bad_NotFound(t *testing.T) {
+ m := io.NewMockMedium()
+ reg := NewRegistry(m, "/plugins")
+ inst := NewInstaller(m, reg)
+
+ err := inst.Remove("nonexistent")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "plugin not found")
+}
+
+// ── Update error paths ─────────────────────────────────────────────
+
+func TestUpdate_Bad_NotFound(t *testing.T) {
+ m := io.NewMockMedium()
+ reg := NewRegistry(m, "/plugins")
+ inst := NewInstaller(m, reg)
+
+ err := inst.Update(context.Background(), "missing")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "plugin not found")
+}
+
+// ── ParseSource ────────────────────────────────────────────────────
+
func TestParseSource_Good_OrgRepo(t *testing.T) {
org, repo, version, err := ParseSource("host-uk/core-plugin")
assert.NoError(t, err)
diff --git a/pkg/plugin/registry_test.go b/pkg/plugin/registry_test.go
index cedfaed..ff25d33 100644
--- a/pkg/plugin/registry_test.go
+++ b/pkg/plugin/registry_test.go
@@ -134,3 +134,59 @@ func TestRegistry_Load_Good_EmptyWhenNoFile(t *testing.T) {
assert.NoError(t, err)
assert.Empty(t, reg.List())
}
+
+func TestRegistry_Load_Bad_InvalidJSON(t *testing.T) {
+ m := io.NewMockMedium()
+ basePath := "/home/user/.core/plugins"
+ _ = m.Write(basePath+"/registry.json", "not valid json {{{")
+
+ reg := NewRegistry(m, basePath)
+ err := reg.Load()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to parse registry")
+}
+
+func TestRegistry_Load_Good_NullJSON(t *testing.T) {
+ m := io.NewMockMedium()
+ basePath := "/home/user/.core/plugins"
+ _ = m.Write(basePath+"/registry.json", "null")
+
+ reg := NewRegistry(m, basePath)
+ err := reg.Load()
+ assert.NoError(t, err)
+ assert.Empty(t, reg.List())
+}
+
+func TestRegistry_Save_Good_CreatesDir(t *testing.T) {
+ m := io.NewMockMedium()
+ basePath := "/home/user/.core/plugins"
+ reg := NewRegistry(m, basePath)
+
+ _ = reg.Add(&PluginConfig{Name: "test", Version: "1.0.0"})
+ err := reg.Save()
+ assert.NoError(t, err)
+
+ // Verify file was written.
+ assert.True(t, m.IsFile(basePath+"/registry.json"))
+}
+
+func TestRegistry_List_Good_Sorted(t *testing.T) {
+ m := io.NewMockMedium()
+ reg := NewRegistry(m, "/plugins")
+
+ _ = reg.Add(&PluginConfig{Name: "zebra", Version: "1.0.0"})
+ _ = reg.Add(&PluginConfig{Name: "alpha", Version: "1.0.0"})
+ _ = reg.Add(&PluginConfig{Name: "middle", Version: "1.0.0"})
+
+ list := reg.List()
+ assert.Len(t, list, 3)
+ assert.Equal(t, "alpha", list[0].Name)
+ assert.Equal(t, "middle", list[1].Name)
+ assert.Equal(t, "zebra", list[2].Name)
+}
+
+func TestRegistry_RegistryPath_Good(t *testing.T) {
+ m := io.NewMockMedium()
+ reg := NewRegistry(m, "/base/path")
+ assert.Equal(t, "/base/path/registry.json", reg.registryPath())
+}
diff --git a/pkg/repos/registry_test.go b/pkg/repos/registry_test.go
index 52e417d..1b5ad1a 100644
--- a/pkg/repos/registry_test.go
+++ b/pkg/repos/registry_test.go
@@ -5,9 +5,12 @@ import (
"forge.lthn.ai/core/go/pkg/io"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
-func TestLoadRegistry(t *testing.T) {
+// ── LoadRegistry ───────────────────────────────────────────────────
+
+func TestLoadRegistry_Good(t *testing.T) {
m := io.NewMockMedium()
yaml := `
version: 1
@@ -34,7 +37,401 @@ repos:
assert.Equal(t, reg, repo.registry)
}
-func TestRepo_Exists(t *testing.T) {
+func TestLoadRegistry_Good_WithDefaults(t *testing.T) {
+ m := io.NewMockMedium()
+ yaml := `
+version: 1
+org: host-uk
+base_path: /tmp/repos
+defaults:
+ ci: github-actions
+ license: EUPL-1.2
+ branch: main
+repos:
+ core-php:
+ type: foundation
+ description: Foundation
+ core-admin:
+ type: module
+ description: Admin panel
+`
+ _ = m.Write("/tmp/repos.yaml", yaml)
+
+ reg, err := LoadRegistry(m, "/tmp/repos.yaml")
+ require.NoError(t, err)
+
+ php, ok := reg.Get("core-php")
+ require.True(t, ok)
+ assert.Equal(t, "github-actions", php.CI)
+
+ admin, ok := reg.Get("core-admin")
+ require.True(t, ok)
+ assert.Equal(t, "github-actions", admin.CI)
+}
+
+func TestLoadRegistry_Good_CustomRepoPath(t *testing.T) {
+ m := io.NewMockMedium()
+ yaml := `
+version: 1
+org: host-uk
+base_path: /tmp/repos
+repos:
+ special:
+ type: module
+ path: /opt/special-repo
+`
+ _ = m.Write("/tmp/repos.yaml", yaml)
+
+ reg, err := LoadRegistry(m, "/tmp/repos.yaml")
+ require.NoError(t, err)
+
+ repo, ok := reg.Get("special")
+ require.True(t, ok)
+ assert.Equal(t, "/opt/special-repo", repo.Path)
+}
+
+func TestLoadRegistry_Good_CIOverride(t *testing.T) {
+ m := io.NewMockMedium()
+ yaml := `
+version: 1
+org: test
+base_path: /tmp
+defaults:
+ ci: default-ci
+repos:
+ a:
+ type: module
+ b:
+ type: module
+ ci: custom-ci
+`
+ _ = m.Write("/tmp/repos.yaml", yaml)
+
+ reg, err := LoadRegistry(m, "/tmp/repos.yaml")
+ require.NoError(t, err)
+
+ a, _ := reg.Get("a")
+ assert.Equal(t, "default-ci", a.CI)
+
+ b, _ := reg.Get("b")
+ assert.Equal(t, "custom-ci", b.CI)
+}
+
+func TestLoadRegistry_Bad_FileNotFound(t *testing.T) {
+ m := io.NewMockMedium()
+ _, err := LoadRegistry(m, "/nonexistent/repos.yaml")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to read")
+}
+
+func TestLoadRegistry_Bad_InvalidYAML(t *testing.T) {
+ m := io.NewMockMedium()
+ _ = m.Write("/tmp/bad.yaml", "{{{{not yaml at all")
+
+ _, err := LoadRegistry(m, "/tmp/bad.yaml")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to parse")
+}
+
+// ── List / Get / ByType ────────────────────────────────────────────
+
+func newTestRegistry(t *testing.T) *Registry {
+ t.Helper()
+ m := io.NewMockMedium()
+ yaml := `
+version: 1
+org: host-uk
+base_path: /tmp/repos
+repos:
+ core-php:
+ type: foundation
+ description: Foundation
+ core-admin:
+ type: module
+ depends_on: [core-php]
+ description: Admin
+ core-tenant:
+ type: module
+ depends_on: [core-php]
+ description: Tenancy
+ core-bio:
+ type: product
+ depends_on: [core-php, core-tenant]
+ description: Bio product
+`
+ _ = m.Write("/tmp/repos.yaml", yaml)
+ reg, err := LoadRegistry(m, "/tmp/repos.yaml")
+ require.NoError(t, err)
+ return reg
+}
+
+func TestRegistry_List_Good(t *testing.T) {
+ reg := newTestRegistry(t)
+ repos := reg.List()
+ assert.Len(t, repos, 4)
+}
+
+func TestRegistry_Get_Good(t *testing.T) {
+ reg := newTestRegistry(t)
+ repo, ok := reg.Get("core-php")
+ assert.True(t, ok)
+ assert.Equal(t, "core-php", repo.Name)
+}
+
+func TestRegistry_Get_Bad_NotFound(t *testing.T) {
+ reg := newTestRegistry(t)
+ _, ok := reg.Get("nonexistent")
+ assert.False(t, ok)
+}
+
+func TestRegistry_ByType_Good(t *testing.T) {
+ reg := newTestRegistry(t)
+
+ foundations := reg.ByType("foundation")
+ assert.Len(t, foundations, 1)
+ assert.Equal(t, "core-php", foundations[0].Name)
+
+ modules := reg.ByType("module")
+ assert.Len(t, modules, 2)
+
+ products := reg.ByType("product")
+ assert.Len(t, products, 1)
+}
+
+func TestRegistry_ByType_Good_NoMatch(t *testing.T) {
+ reg := newTestRegistry(t)
+ templates := reg.ByType("template")
+ assert.Empty(t, templates)
+}
+
+// ── TopologicalOrder ───────────────────────────────────────────────
+
+func TestTopologicalOrder_Good(t *testing.T) {
+ reg := newTestRegistry(t)
+ order, err := TopologicalOrder(reg)
+ require.NoError(t, err)
+ assert.Len(t, order, 4)
+
+ // core-php must come before everything that depends on it.
+ phpIdx := -1
+ for i, r := range order {
+ if r.Name == "core-php" {
+ phpIdx = i
+ break
+ }
+ }
+ require.GreaterOrEqual(t, phpIdx, 0, "core-php not found")
+
+ for i, r := range order {
+ for _, dep := range r.DependsOn {
+ depIdx := -1
+ for j, d := range order {
+ if d.Name == dep {
+ depIdx = j
+ break
+ }
+ }
+ assert.Less(t, depIdx, i, "%s should come before %s", dep, r.Name)
+ }
+ }
+}
+
+func TopologicalOrder(reg *Registry) ([]*Repo, error) {
+ return reg.TopologicalOrder()
+}
+
+func TestTopologicalOrder_Bad_CircularDep(t *testing.T) {
+ m := io.NewMockMedium()
+ yaml := `
+version: 1
+org: test
+base_path: /tmp
+repos:
+ a:
+ type: module
+ depends_on: [b]
+ b:
+ type: module
+ depends_on: [a]
+`
+ _ = m.Write("/tmp/repos.yaml", yaml)
+ reg, err := LoadRegistry(m, "/tmp/repos.yaml")
+ require.NoError(t, err)
+
+ _, err = reg.TopologicalOrder()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "circular dependency")
+}
+
+func TestTopologicalOrder_Bad_UnknownDep(t *testing.T) {
+ m := io.NewMockMedium()
+ yaml := `
+version: 1
+org: test
+base_path: /tmp
+repos:
+ a:
+ type: module
+ depends_on: [nonexistent]
+`
+ _ = m.Write("/tmp/repos.yaml", yaml)
+ reg, err := LoadRegistry(m, "/tmp/repos.yaml")
+ require.NoError(t, err)
+
+ _, err = reg.TopologicalOrder()
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "unknown repo")
+}
+
+func TestTopologicalOrder_Good_NoDeps(t *testing.T) {
+ m := io.NewMockMedium()
+ yaml := `
+version: 1
+org: test
+base_path: /tmp
+repos:
+ a:
+ type: module
+ b:
+ type: module
+`
+ _ = m.Write("/tmp/repos.yaml", yaml)
+ reg, err := LoadRegistry(m, "/tmp/repos.yaml")
+ require.NoError(t, err)
+
+ order, err := reg.TopologicalOrder()
+ require.NoError(t, err)
+ assert.Len(t, order, 2)
+}
+
+// ── ScanDirectory ──────────────────────────────────────────────────
+
+func TestScanDirectory_Good(t *testing.T) {
+ m := io.NewMockMedium()
+
+ // Create mock repos with .git dirs.
+ _ = m.EnsureDir("/workspace/repo-a/.git")
+ _ = m.EnsureDir("/workspace/repo-b/.git")
+ _ = m.EnsureDir("/workspace/not-a-repo") // No .git
+
+ // Write a file (not a dir) at top level.
+ _ = m.Write("/workspace/README.md", "hello")
+
+ reg, err := ScanDirectory(m, "/workspace")
+ require.NoError(t, err)
+
+ assert.Len(t, reg.Repos, 2)
+
+ a, ok := reg.Repos["repo-a"]
+ assert.True(t, ok)
+ assert.Equal(t, "/workspace/repo-a", a.Path)
+ assert.Equal(t, "module", a.Type) // Default type.
+
+ _, ok = reg.Repos["not-a-repo"]
+ assert.False(t, ok)
+}
+
+func TestScanDirectory_Good_DetectsGitHubOrg(t *testing.T) {
+ m := io.NewMockMedium()
+
+ _ = m.EnsureDir("/workspace/my-repo/.git")
+ _ = m.Write("/workspace/my-repo/.git/config", `[core]
+ repositoryformatversion = 0
+[remote "origin"]
+ url = git@github.com:host-uk/my-repo.git
+ fetch = +refs/heads/*:refs/remotes/origin/*
+`)
+
+ reg, err := ScanDirectory(m, "/workspace")
+ require.NoError(t, err)
+ assert.Equal(t, "host-uk", reg.Org)
+}
+
+func TestScanDirectory_Good_DetectsHTTPSOrg(t *testing.T) {
+ m := io.NewMockMedium()
+
+ _ = m.EnsureDir("/workspace/my-repo/.git")
+ _ = m.Write("/workspace/my-repo/.git/config", `[remote "origin"]
+ url = https://github.com/lethean-io/my-repo.git
+`)
+
+ reg, err := ScanDirectory(m, "/workspace")
+ require.NoError(t, err)
+ assert.Equal(t, "lethean-io", reg.Org)
+}
+
+func TestScanDirectory_Good_EmptyDir(t *testing.T) {
+ m := io.NewMockMedium()
+ _ = m.EnsureDir("/empty")
+
+ reg, err := ScanDirectory(m, "/empty")
+ require.NoError(t, err)
+ assert.Empty(t, reg.Repos)
+ assert.Equal(t, "", reg.Org)
+}
+
+func TestScanDirectory_Bad_InvalidDir(t *testing.T) {
+ m := io.NewMockMedium()
+ _, err := ScanDirectory(m, "/nonexistent")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to read directory")
+}
+
+// ── detectOrg ──────────────────────────────────────────────────────
+
+func TestDetectOrg_Good_SSHRemote(t *testing.T) {
+ m := io.NewMockMedium()
+ _ = m.Write("/repo/.git/config", `[remote "origin"]
+ url = git@github.com:host-uk/core.git
+`)
+ assert.Equal(t, "host-uk", detectOrg(m, "/repo"))
+}
+
+func TestDetectOrg_Good_HTTPSRemote(t *testing.T) {
+ m := io.NewMockMedium()
+ _ = m.Write("/repo/.git/config", `[remote "origin"]
+ url = https://github.com/snider/project.git
+`)
+ assert.Equal(t, "snider", detectOrg(m, "/repo"))
+}
+
+func TestDetectOrg_Bad_NoConfig(t *testing.T) {
+ m := io.NewMockMedium()
+ assert.Equal(t, "", detectOrg(m, "/nonexistent"))
+}
+
+func TestDetectOrg_Bad_NoRemote(t *testing.T) {
+ m := io.NewMockMedium()
+ _ = m.Write("/repo/.git/config", `[core]
+ repositoryformatversion = 0
+`)
+ assert.Equal(t, "", detectOrg(m, "/repo"))
+}
+
+func TestDetectOrg_Bad_NonGitHubRemote(t *testing.T) {
+ m := io.NewMockMedium()
+ _ = m.Write("/repo/.git/config", `[remote "origin"]
+ url = ssh://git@forge.lthn.ai:2223/core/go.git
+`)
+ assert.Equal(t, "", detectOrg(m, "/repo"))
+}
+
+// ── expandPath ─────────────────────────────────────────────────────
+
+func TestExpandPath_Good_Tilde(t *testing.T) {
+ got := expandPath("~/Code/repos")
+ assert.NotContains(t, got, "~")
+ assert.Contains(t, got, "Code/repos")
+}
+
+func TestExpandPath_Good_NoTilde(t *testing.T) {
+ assert.Equal(t, "/absolute/path", expandPath("/absolute/path"))
+ assert.Equal(t, "relative/path", expandPath("relative/path"))
+}
+
+// ── Repo.Exists / IsGitRepo ───────────────────────────────────────
+
+func TestRepo_Exists_Good(t *testing.T) {
m := io.NewMockMedium()
reg := &Registry{
medium: m,
@@ -47,15 +444,13 @@ func TestRepo_Exists(t *testing.T) {
registry: reg,
}
- // Not exists yet
assert.False(t, repo.Exists())
- // Create directory in mock
_ = m.EnsureDir("/tmp/repos/core")
assert.True(t, repo.Exists())
}
-func TestRepo_IsGitRepo(t *testing.T) {
+func TestRepo_IsGitRepo_Good(t *testing.T) {
m := io.NewMockMedium()
reg := &Registry{
medium: m,
@@ -68,10 +463,24 @@ func TestRepo_IsGitRepo(t *testing.T) {
registry: reg,
}
- // Not a git repo yet
assert.False(t, repo.IsGitRepo())
- // Create .git directory in mock
_ = m.EnsureDir("/tmp/repos/core/.git")
assert.True(t, repo.IsGitRepo())
}
+
+// ── getMedium fallback ─────────────────────────────────────────────
+
+func TestGetMedium_Good_FallbackToLocal(t *testing.T) {
+ repo := &Repo{Name: "orphan", Path: "/tmp/orphan"}
+ // No registry set — should fall back to io.Local.
+ m := repo.getMedium()
+ assert.Equal(t, io.Local, m)
+}
+
+func TestGetMedium_Good_NilMediumFallback(t *testing.T) {
+ reg := &Registry{} // medium is nil.
+ repo := &Repo{Name: "test", registry: reg}
+ m := repo.getMedium()
+ assert.Equal(t, io.Local, m)
+}
diff --git a/pkg/session/html_test.go b/pkg/session/html_test.go
new file mode 100644
index 0000000..9b0a98d
--- /dev/null
+++ b/pkg/session/html_test.go
@@ -0,0 +1,194 @@
+package session
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestRenderHTML_Good_BasicSession(t *testing.T) {
+ dir := t.TempDir()
+ out := filepath.Join(dir, "session.html")
+
+ sess := &Session{
+ ID: "f3fb074c-8c72-4da6-a15a-85bae652ccaa",
+ StartTime: time.Date(2026, 2, 24, 10, 0, 0, 0, time.UTC),
+ EndTime: time.Date(2026, 2, 24, 10, 5, 0, 0, time.UTC),
+ Events: []Event{
+ {
+ Timestamp: time.Date(2026, 2, 24, 10, 0, 5, 0, time.UTC),
+ Type: "tool_use",
+ Tool: "Bash",
+ Input: "go test ./...",
+ Output: "ok forge.lthn.ai/core/go 1.2s",
+ Duration: time.Second,
+ Success: true,
+ },
+ {
+ Timestamp: time.Date(2026, 2, 24, 10, 1, 0, 0, time.UTC),
+ Type: "tool_use",
+ Tool: "Read",
+ Input: "/tmp/test.go",
+ Output: "package main",
+ Duration: 200 * time.Millisecond,
+ Success: true,
+ },
+ {
+ Timestamp: time.Date(2026, 2, 24, 10, 2, 0, 0, time.UTC),
+ Type: "user",
+ Input: "looks good",
+ },
+ },
+ }
+
+ if err := RenderHTML(sess, out); err != nil {
+ t.Fatal(err)
+ }
+
+ data, err := os.ReadFile(out)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ html := string(data)
+ if !strings.Contains(html, "f3fb074c") {
+ t.Fatal("missing session ID")
+ }
+ if !strings.Contains(html, "go test ./...") {
+ t.Fatal("missing bash command")
+ }
+ if !strings.Contains(html, "2 tool calls") {
+ t.Fatal("missing tool count")
+ }
+ if !strings.Contains(html, "filterEvents") {
+ t.Fatal("missing JS filter function")
+ }
+}
+
+func TestRenderHTML_Good_WithErrors(t *testing.T) {
+ dir := t.TempDir()
+ out := filepath.Join(dir, "errors.html")
+
+ sess := &Session{
+ ID: "err-session",
+ StartTime: time.Date(2026, 2, 24, 10, 0, 0, 0, time.UTC),
+ EndTime: time.Date(2026, 2, 24, 10, 1, 0, 0, time.UTC),
+ Events: []Event{
+ {
+ Type: "tool_use", Tool: "Bash",
+ Timestamp: time.Date(2026, 2, 24, 10, 0, 0, 0, time.UTC),
+ Input: "bad command", Output: "error", Success: false,
+ },
+ },
+ }
+
+ if err := RenderHTML(sess, out); err != nil {
+ t.Fatal(err)
+ }
+
+ data, _ := os.ReadFile(out)
+ html := string(data)
+ if !strings.Contains(html, "1 errors") {
+ t.Fatal("missing error count")
+ }
+ if !strings.Contains(html, `class="event error"`) {
+ t.Fatal("missing error class")
+ }
+ if !strings.Contains(html, "✗") {
+ t.Fatal("missing failure icon")
+ }
+}
+
+func TestRenderHTML_Good_AssistantEvent(t *testing.T) {
+ dir := t.TempDir()
+ out := filepath.Join(dir, "asst.html")
+
+ sess := &Session{
+ ID: "asst-test",
+ StartTime: time.Date(2026, 2, 24, 10, 0, 0, 0, time.UTC),
+ EndTime: time.Date(2026, 2, 24, 10, 0, 5, 0, time.UTC),
+ Events: []Event{
+ {
+ Type: "assistant",
+ Timestamp: time.Date(2026, 2, 24, 10, 0, 0, 0, time.UTC),
+ Input: "Let me check that.",
+ },
+ },
+ }
+
+ if err := RenderHTML(sess, out); err != nil {
+ t.Fatal(err)
+ }
+
+ data, _ := os.ReadFile(out)
+ if !strings.Contains(string(data), "Claude") {
+ t.Fatal("missing Claude label for assistant")
+ }
+}
+
+func TestRenderHTML_Good_EmptySession(t *testing.T) {
+ dir := t.TempDir()
+ out := filepath.Join(dir, "empty.html")
+
+ sess := &Session{
+ ID: "empty",
+ StartTime: time.Now(),
+ EndTime: time.Now(),
+ }
+
+ if err := RenderHTML(sess, out); err != nil {
+ t.Fatal(err)
+ }
+
+ info, err := os.Stat(out)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if info.Size() == 0 {
+ t.Fatal("HTML file is empty")
+ }
+}
+
+func TestRenderHTML_Bad_InvalidPath(t *testing.T) {
+ sess := &Session{ID: "test", StartTime: time.Now(), EndTime: time.Now()}
+ err := RenderHTML(sess, "/nonexistent/dir/out.html")
+ if err == nil {
+ t.Fatal("expected error for invalid path")
+ }
+}
+
+func TestRenderHTML_Good_XSSEscaping(t *testing.T) {
+ dir := t.TempDir()
+ out := filepath.Join(dir, "xss.html")
+
+ sess := &Session{
+ ID: "xss-test",
+ StartTime: time.Now(),
+ EndTime: time.Now(),
+ Events: []Event{
+ {
+ Type: "tool_use",
+ Tool: "Bash",
+ Timestamp: time.Now(),
+ Input: `echo ""`,
+ Output: `
`,
+ Success: true,
+ },
+ },
+ }
+
+ if err := RenderHTML(sess, out); err != nil {
+ t.Fatal(err)
+ }
+
+ data, _ := os.ReadFile(out)
+ html := string(data)
+ if strings.Contains(html, "