mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
Chore: clean up staged changes for release
- Remove standalone pulse-assistant architecture doc (content lives in CLAUDE.md) - Add CountdownTimer component for patrol schedule display - Rewrite patrol handler test to focus on interval persistence - Extract MockStateProvider to shared test file
This commit is contained in:
File diff suppressed because it is too large
Load Diff
60
frontend-modern/src/components/patrol/CountdownTimer.tsx
Normal file
60
frontend-modern/src/components/patrol/CountdownTimer.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { createSignal, onCleanup, onMount, createEffect } from 'solid-js';
|
||||
|
||||
interface CountdownTimerProps {
|
||||
targetDate: string;
|
||||
prefix?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CountdownTimer(props: CountdownTimerProps) {
|
||||
const [timeLeft, setTimeLeft] = createSignal('');
|
||||
|
||||
const calculateTimeLeft = () => {
|
||||
const now = new Date();
|
||||
const target = new Date(props.targetDate);
|
||||
const diffMs = target.getTime() - now.getTime();
|
||||
|
||||
if (diffMs <= 0) {
|
||||
return 'Due now';
|
||||
}
|
||||
|
||||
const diffSecs = Math.floor(diffMs / 1000);
|
||||
const hours = Math.floor(diffSecs / 3600);
|
||||
const minutes = Math.floor((diffSecs % 3600) / 60);
|
||||
const seconds = diffSecs % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
let timer: number | undefined;
|
||||
|
||||
const update = () => {
|
||||
setTimeLeft(calculateTimeLeft());
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
// Reset/update immediately when prop changes
|
||||
if (props.targetDate) {
|
||||
update();
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
update();
|
||||
timer = setInterval(update, 1000) as unknown as number;
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
clearInterval(timer);
|
||||
});
|
||||
|
||||
return (
|
||||
<span class={props.className}>
|
||||
{props.prefix}{timeLeft()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -7,3 +7,4 @@ export { RunToolCallTrace } from './RunToolCallTrace';
|
||||
export { PatrolStatusBar } from './PatrolStatusBar';
|
||||
export { RunHistoryEntry } from './RunHistoryEntry';
|
||||
export { RunHistoryPanel } from './RunHistoryPanel';
|
||||
export { CountdownTimer } from './CountdownTimer';
|
||||
|
||||
@@ -1,162 +1,70 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// MockStateProvider for initializing PatrolService
|
||||
type MockStateProvider struct{}
|
||||
func TestAISettingsHandler_PatrolInterval_SpecificCheck(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
func (m *MockStateProvider) GetState() models.StateSnapshot {
|
||||
return models.StateSnapshot{}
|
||||
}
|
||||
tmp := t.TempDir()
|
||||
cfg := &config.Config{DataPath: tmp}
|
||||
persistence := config.NewConfigPersistence(tmp)
|
||||
|
||||
// MockOrchestrator for testing GetFixedCount integration
|
||||
type MockOrchestrator struct {
|
||||
FixedCount int
|
||||
}
|
||||
handler := newTestAISettingsHandler(cfg, persistence, nil)
|
||||
|
||||
func (m *MockOrchestrator) InvestigateFinding(ctx context.Context, finding *ai.InvestigationFinding, autonomyLevel string) error {
|
||||
return nil
|
||||
}
|
||||
func (m *MockOrchestrator) GetInvestigationByFinding(findingID string) *ai.InvestigationSession {
|
||||
return nil
|
||||
}
|
||||
func (m *MockOrchestrator) GetRunningCount() int {
|
||||
return 0
|
||||
}
|
||||
func (m *MockOrchestrator) GetFixedCount() int {
|
||||
return m.FixedCount
|
||||
}
|
||||
func (m *MockOrchestrator) CanStartInvestigation() bool {
|
||||
return true
|
||||
}
|
||||
func (m *MockOrchestrator) ReinvestigateFinding(ctx context.Context, findingID, autonomyLevel string) error {
|
||||
return nil
|
||||
}
|
||||
func (m *MockOrchestrator) Shutdown(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
// Step 1: Update settings to set patrol interval to 15 minutes explicitly
|
||||
{
|
||||
body, _ := json.Marshal(AISettingsUpdateRequest{
|
||||
PatrolIntervalMinutes: ptr(15),
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/settings/ai", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleUpdateAISettings(rec, req)
|
||||
|
||||
func TestHandleGetPatrolStatus_Integration(t *testing.T) {
|
||||
// Setup temporary persistence
|
||||
tempDir := t.TempDir()
|
||||
// persistence := config.NewConfigPersistence(tempDir) // Unused
|
||||
mtPersistence := config.NewMultiTenantPersistence(tempDir)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("PUT status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Create handler (this will initialize legacyAIService inside)
|
||||
handler := NewAISettingsHandler(mtPersistence, nil, nil)
|
||||
// Manually inject legacy persistence since NewAISettingsHandler uses it for default service
|
||||
// The constructor initializes legacyAIService if defaultPersistence is provided.
|
||||
// We need to match how the constructor works.
|
||||
// Since NewAISettingsHandler takes *MultiTenantPersistence, we need to ensure it can get "default" persistence
|
||||
// OR we can manually set up the legacy service if the constructor behavior is complex.
|
||||
var resp AISettingsResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
// Better approach: mimic constructor logic by ensuring "default" exists or use a helper?
|
||||
// Actually NewAISettingsHandler code:
|
||||
// if mtp != nil { if p, err := mtp.GetPersistence("default"); ... defaultPersistence = p }
|
||||
// So we need to ensure "default" persistence is available in mtPersistence?
|
||||
// config.NewMultiTenantPersistence creates the dir but maybe not the sub-persistence until requested?
|
||||
// Let's rely on the fact that GetAIService("default") calls mtPersistence.GetPersistence("default")
|
||||
// The API response should return 15 minutes, NOT 360 (6 hours)
|
||||
if resp.PatrolIntervalMinutes != 15 {
|
||||
t.Fatalf("expected PatrolIntervalMinutes=15, got %d. Did the migration logic override the user setting?", resp.PatrolIntervalMinutes)
|
||||
}
|
||||
|
||||
// Let's explicitly setup the handler's internal service for the test context
|
||||
svc := handler.GetAIService(context.Background())
|
||||
if svc == nil {
|
||||
// Fallback: manually set it if constructor didn't pick it up (likely due to empty mtp)
|
||||
// But GetAIService creates it if missing for orgID.
|
||||
// For "default", it returns legacyAIService.
|
||||
// Check that the preset is cleared as expected when setting explicit minutes
|
||||
if resp.PatrolSchedulePreset != "" {
|
||||
t.Fatalf("expected PatrolSchedulePreset to be empty, got %q", resp.PatrolSchedulePreset)
|
||||
}
|
||||
}
|
||||
|
||||
// To force a clean service creation we can use a specific tenant ID context
|
||||
ctx := context.WithValue(context.Background(), "org_id", "test-org")
|
||||
svc = handler.GetAIService(ctx)
|
||||
require.NotNil(t, svc, "Service should be created for new tenant")
|
||||
// Step 2: Verify persistence by fetching settings again
|
||||
{
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/settings/ai", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleGetAISettings(rec, req)
|
||||
|
||||
// Initialize PatrolService by setting StateProvider
|
||||
svc.SetStateProvider(&MockStateProvider{})
|
||||
patrol := svc.GetPatrolService()
|
||||
require.NotNil(t, patrol, "PatrolService should be initialized")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Inject MockOrchestrator with specific FixedCount
|
||||
mockOrch := &MockOrchestrator{FixedCount: 42}
|
||||
patrol.SetInvestigationOrchestrator(mockOrch)
|
||||
var resp AISettingsResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
// Make request
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/status", nil)
|
||||
// Inject org_id into context so handler uses the same service
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.HandleGetPatrolStatus(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify FixedCount is propagated
|
||||
// Use float64 because JSON unmarshals numbers as floats
|
||||
require.Equal(t, 42.0, response["fixed_count"], "fixed_count should match mocked value")
|
||||
|
||||
// Verify other fields exist
|
||||
require.Contains(t, response, "running")
|
||||
require.Contains(t, response, "healthy")
|
||||
} else {
|
||||
// If service was found (default case)
|
||||
svc.SetStateProvider(&MockStateProvider{})
|
||||
patrol := svc.GetPatrolService()
|
||||
require.NotNil(t, patrol)
|
||||
|
||||
mockOrch := &MockOrchestrator{FixedCount: 42}
|
||||
patrol.SetInvestigationOrchestrator(mockOrch)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/status", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.HandleGetPatrolStatus(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, 42.0, response["fixed_count"])
|
||||
if resp.PatrolIntervalMinutes != 15 {
|
||||
t.Fatalf("expected persisted PatrolIntervalMinutes=15, got %d", resp.PatrolIntervalMinutes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to handle context key if needed (assuming GetOrgID uses a specific key)
|
||||
// But since we can't import internal/api's internal helpers easily if they are unexported,
|
||||
// we rely on standard behavior or just use the default path if possible.
|
||||
// internal/api/utils.go likely has GetOrgID. Ideally we'd test the default path.
|
||||
|
||||
func TestHandleGetPatrolStatus_NotInitialized(t *testing.T) {
|
||||
// Setup handler with NO state provider -> NO patrol service
|
||||
tempDir := t.TempDir()
|
||||
mtPersistence := config.NewMultiTenantPersistence(tempDir)
|
||||
handler := NewAISettingsHandler(mtPersistence, nil, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/status", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.HandleGetPatrolStatus(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should indicate not enabled/running
|
||||
require.Equal(t, false, response["running"])
|
||||
require.Equal(t, false, response["enabled"])
|
||||
}
|
||||
|
||||
14
internal/api/mock_state_provider_test.go
Normal file
14
internal/api/mock_state_provider_test.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
)
|
||||
|
||||
// MockStateProvider is a mock implementation of ai.StateProvider for API tests
|
||||
type MockStateProvider struct {
|
||||
State models.StateSnapshot
|
||||
}
|
||||
|
||||
func (m *MockStateProvider) GetState() models.StateSnapshot {
|
||||
return m.State
|
||||
}
|
||||
Reference in New Issue
Block a user