Files
poimen-workflows/internal/board/state_test.go
T
Test b1e3136350 feat(T1.4): implement board state validation and auto-healing
- Add internal/board package with validation and state tracking
- Implement BoardValidator for comprehensive board file validation
- Detect missing headers, malformed tables, invalid task IDs
- Validate status fields ([x] or [ ])
- Parse task information from valid boards
- Implement StateTracker for actual task state management
- Track task progression (pending → in_progress → completed/failed)
- Support task metrics attachment and analytics
- Implement divergence detection: compare board vs actual states
- Implement auto-healing: fix state mismatches between board and reality
- RepairBoard() fixes structural corruption issues
- HealDivergence() updates board to match actual states
- Support both JSON persistence and in-memory operation

Validation Features:
- Detailed error reporting with line numbers and context
- Warning system for suspicious but valid boards
- Task ID format validation (T#.# pattern)
- Status value normalization ([X] → [x])
- Table structure verification

State Management:
- Persistent JSON storage of task states
- Completion/failure timestamps
- Custom metrics per task
- Thread-safe RWMutex synchronization
- Stats and filtering operations

Healing Features:
- Non-destructive repairs (report changes)
- Board integrity preservation
- Divergence detection with timestamps
- Batch update capability
- Change tracking for audit trail

Test Coverage:
- 13 validator tests (structure, validation, repair, parsing)
- 16 state tracker tests (tracking, persistence, analytics)
- 29 total board tests, all passing
- Edge cases: empty boards, invalid formats, multiple tasks
- Multi-state transitions and metrics

Key Design:
- Separation of concerns: Validator (format) vs Tracker (state)
- JSON persistence (human-readable, debuggable)
- Thread-safe concurrent state updates
- Detailed error messages with context
- Non-breaking repairs (safe by default)

Closes T1.4
2026-08-23 16:49:25 -07:00

228 lines
5.9 KiB
Go

package board
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestStateTracker(t *testing.T) {
tmpDir := t.TempDir()
st := NewStateTracker(tmpDir)
// Update a task state
err := st.UpdateTaskState("T1.1", "completed", "task/T1.1", nil)
assert.NoError(t, err)
// Retrieve the state
state := st.GetTaskState("T1.1")
assert.NotNil(t, state)
assert.Equal(t, "T1.1", state.TaskID)
assert.Equal(t, "completed", state.Status)
assert.NotZero(t, state.CompletedAt)
}
func TestGetAllStates(t *testing.T) {
tmpDir := t.TempDir()
st := NewStateTracker(tmpDir)
st.UpdateTaskState("T1.1", "completed", "task/T1.1", nil)
st.UpdateTaskState("T1.2", "in_progress", "task/T1.2", nil)
st.UpdateTaskState("T1.3", "pending", "task/T1.3", nil)
states := st.GetAllStates()
assert.Equal(t, 3, len(states))
}
func TestGetCompletedTasks(t *testing.T) {
tmpDir := t.TempDir()
st := NewStateTracker(tmpDir)
st.UpdateTaskState("T1.1", "completed", "task/T1.1", nil)
st.UpdateTaskState("T1.2", "completed", "task/T1.2", nil)
st.UpdateTaskState("T1.3", "pending", "task/T1.3", nil)
completed := st.GetCompletedTasks()
assert.Equal(t, 2, len(completed))
assert.Contains(t, completed, "T1.1")
assert.Contains(t, completed, "T1.2")
}
func TestGetFailedTasks(t *testing.T) {
tmpDir := t.TempDir()
st := NewStateTracker(tmpDir)
err := assert.AnError
st.UpdateTaskState("T1.1", "failed", "task/T1.1", err)
st.UpdateTaskState("T1.2", "completed", "task/T1.2", nil)
failed := st.GetFailedTasks()
assert.Equal(t, 1, len(failed))
assert.Equal(t, "T1.1", failed[0])
}
func TestGetPendingTasks(t *testing.T) {
tmpDir := t.TempDir()
st := NewStateTracker(tmpDir)
st.UpdateTaskState("T1.1", "pending", "task/T1.1", nil)
st.UpdateTaskState("T1.2", "in_progress", "task/T1.2", nil)
st.UpdateTaskState("T1.3", "completed", "task/T1.3", nil)
pending := st.GetPendingTasks()
assert.Equal(t, 2, len(pending))
}
func TestAddMetric(t *testing.T) {
tmpDir := t.TempDir()
st := NewStateTracker(tmpDir)
st.UpdateTaskState("T1.1", "in_progress", "task/T1.1", nil)
err := st.AddMetric("T1.1", "duration_seconds", 42.5)
assert.NoError(t, err)
state := st.GetTaskState("T1.1")
assert.NotNil(t, state.Metrics["duration_seconds"])
assert.Equal(t, 42.5, state.Metrics["duration_seconds"])
}
func TestAddMetricNonexistent(t *testing.T) {
tmpDir := t.TempDir()
st := NewStateTracker(tmpDir)
err := st.AddMetric("nonexistent", "metric", 123)
assert.Error(t, err)
}
func TestPersistence(t *testing.T) {
tmpDir := t.TempDir()
st1 := NewStateTracker(tmpDir)
st1.UpdateTaskState("T1.1", "completed", "task/T1.1", nil)
st1.UpdateTaskState("T1.2", "pending", "task/T1.2", nil)
// Create new instance and load
st2 := NewStateTracker(tmpDir)
err := st2.Load()
assert.NoError(t, err)
states := st2.GetAllStates()
assert.Equal(t, 2, len(states))
assert.Equal(t, "completed", states["T1.1"].Status)
assert.Equal(t, "pending", states["T1.2"].Status)
}
func TestGetAsCompletionMap(t *testing.T) {
tmpDir := t.TempDir()
st := NewStateTracker(tmpDir)
st.UpdateTaskState("T1.1", "completed", "task/T1.1", nil)
st.UpdateTaskState("T1.2", "pending", "task/T1.2", nil)
st.UpdateTaskState("T1.3", "failed", "task/T1.3", assert.AnError)
completion := st.GetAsCompletionMap()
assert.Equal(t, true, completion["T1.1"])
assert.Equal(t, false, completion["T1.2"])
assert.Equal(t, false, completion["T1.3"])
}
func TestGetLastUpdate(t *testing.T) {
tmpDir := t.TempDir()
st := NewStateTracker(tmpDir)
before := time.Now()
st.UpdateTaskState("T1.1", "completed", "task/T1.1", nil)
after := time.Now()
lastUpdate := st.GetLastUpdate()
assert.True(t, lastUpdate.After(before) || lastUpdate.Equal(before))
assert.True(t, lastUpdate.Before(after) || lastUpdate.Equal(after))
}
func TestGetStats(t *testing.T) {
tmpDir := t.TempDir()
st := NewStateTracker(tmpDir)
st.UpdateTaskState("T1.1", "completed", "task/T1.1", nil)
st.UpdateTaskState("T1.2", "completed", "task/T1.2", nil)
st.UpdateTaskState("T1.3", "pending", "task/T1.3", nil)
st.UpdateTaskState("T1.4", "failed", "task/T1.4", assert.AnError)
stats := st.GetStats()
assert.Equal(t, 4, stats["total"])
counts := stats["counts"].(map[string]int)
assert.Equal(t, 2, counts["completed"])
assert.Equal(t, 1, counts["pending"])
assert.Equal(t, 1, counts["failed"])
}
func TestReset(t *testing.T) {
tmpDir := t.TempDir()
st := NewStateTracker(tmpDir)
st.UpdateTaskState("T1.1", "completed", "task/T1.1", nil)
st.UpdateTaskState("T1.2", "pending", "task/T1.2", nil)
assert.Equal(t, 2, len(st.GetAllStates()))
err := st.Reset()
assert.NoError(t, err)
assert.Equal(t, 0, len(st.GetAllStates()))
}
func TestTaskStateFields(t *testing.T) {
tmpDir := t.TempDir()
st := NewStateTracker(tmpDir)
err := assert.AnError
st.UpdateTaskState("T1.1", "failed", "task/T1.1", err)
state := st.GetTaskState("T1.1")
assert.Equal(t, "T1.1", state.TaskID)
assert.Equal(t, "failed", state.Status)
assert.Equal(t, "task/T1.1", state.Branch)
assert.NotEmpty(t, state.Error)
assert.NotZero(t, state.FailedAt)
}
func TestLoadNonexistentState(t *testing.T) {
tmpDir := t.TempDir()
st := NewStateTracker(tmpDir)
// Should not error when file doesn't exist
err := st.Load()
assert.NoError(t, err)
assert.Equal(t, 0, len(st.GetAllStates()))
}
func TestMultipleStateUpdates(t *testing.T) {
tmpDir := t.TempDir()
st := NewStateTracker(tmpDir)
// Task progresses through states
st.UpdateTaskState("T1.1", "pending", "task/T1.1", nil)
state1 := st.GetTaskState("T1.1")
time.Sleep(10 * time.Millisecond)
st.UpdateTaskState("T1.1", "in_progress", "task/T1.1", nil)
state2 := st.GetTaskState("T1.1")
// Status should be updated
assert.Equal(t, "pending", state1.Status)
assert.Equal(t, "in_progress", state2.Status)
}
func TestStateFileLayout(t *testing.T) {
tmpDir := t.TempDir()
st := NewStateTracker(tmpDir)
st.UpdateTaskState("T1.1", "completed", "task/T1.1", nil)
// Verify state was tracked
state := st.GetTaskState("T1.1")
assert.NotNil(t, state)
}