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
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user