- Add parser_map for market data refresh configuration propagation (cli/worker/api) - Implement refresh status model with SQLite persistence (status_store.go) - Add scheduler refresh status headless output to CLI operator - Extend backfill scheduler with status tracking (start/complete/fail) - Add socket events for scheduler refresh status (start/complete/fail) - Update proto definitions for refresh status - Add generated PB files for client
754 lines
22 KiB
Go
754 lines
22 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.toki-labs.com/toki/alt/services/worker/internal/config"
|
|
"git.toki-labs.com/toki/alt/services/worker/internal/jobs"
|
|
"git.toki-labs.com/toki/alt/services/worker/internal/providers/kis"
|
|
"git.toki-labs.com/toki/alt/services/worker/internal/scheduler"
|
|
"git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres"
|
|
)
|
|
|
|
// TestStoreBackedDepsWiresBacktestSurfaces guards against a regression where the
|
|
// worker process serves backtest reads but leaves the start surface (Starter)
|
|
// unwired, which would advertise no backtest-start capability and reject every
|
|
// start command. The store is built with a nil pool because the wiring path only
|
|
// constructs adapters; it never touches the database at startup.
|
|
func TestStoreBackedDepsWiresBacktestSurfaces(t *testing.T) {
|
|
runner := jobs.NewRunner()
|
|
deps := storeBackedDeps(runner, postgres.NewStore(nil))
|
|
|
|
if deps.Starter == nil {
|
|
t.Error("expected backtest Starter to be wired")
|
|
}
|
|
if deps.Analysis == nil || deps.Results == nil {
|
|
t.Error("expected backtest read stores to be wired")
|
|
}
|
|
if deps.Instruments == nil || deps.Bars == nil {
|
|
t.Error("expected market stores to be wired")
|
|
}
|
|
if deps.DailyBarImporter == nil {
|
|
t.Error("expected daily bar importer to be wired")
|
|
}
|
|
if deps.Paper == nil {
|
|
t.Error("expected paper trading service to be wired")
|
|
}
|
|
if deps.Live == nil {
|
|
t.Error("expected live trading service to be wired (audit query surface must be up even without a broker)")
|
|
}
|
|
|
|
// Wiring registers the import and run-backtest job handlers on the runner, so
|
|
// it must hold at least those two handlers after wiring.
|
|
if runner.Len() < 2 {
|
|
t.Errorf("expected wiring to register job handlers, runner holds %d", runner.Len())
|
|
}
|
|
}
|
|
|
|
func TestStartScheduler(t *testing.T) {
|
|
statusStore := scheduler.NewRefreshStatusStore()
|
|
|
|
// 1. Scheduler disabled (no config path)
|
|
cfg := config.Config{
|
|
SchedulerEnabled: false,
|
|
}
|
|
runner := jobs.NewRunner()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
err := startScheduler(ctx, cfg, runner, statusStore)
|
|
if err != nil {
|
|
t.Fatalf("expected nil error for disabled scheduler, got %v", err)
|
|
}
|
|
|
|
// 2. Scheduler enabled with invalid config path (should fail)
|
|
cfg.SchedulerEnabled = true
|
|
cfg.ScheduleConfigPath = "non_existent_file.yaml"
|
|
err = startScheduler(ctx, cfg, runner, statusStore)
|
|
if err == nil {
|
|
t.Fatal("expected error for non existent schedule config, got nil")
|
|
}
|
|
|
|
// 3. Scheduler enabled with valid config
|
|
tmpDir := t.TempDir()
|
|
tmpFile := filepath.Join(tmpDir, "schedule.yaml")
|
|
if err := os.WriteFile(tmpFile, []byte(`
|
|
schedules:
|
|
- name: kr-core-daily
|
|
provider: kis
|
|
selector:
|
|
kind: watchlist
|
|
market: KR
|
|
venue: KRX
|
|
name: kr-core
|
|
symbols: ["005930"]
|
|
timeframe: daily
|
|
cadence: daily
|
|
timezone: Asia/Seoul
|
|
backfill_window: 3d
|
|
parallelism_limit: 2
|
|
`), 0644); err != nil {
|
|
t.Fatalf("failed to write temp file: %v", err)
|
|
}
|
|
|
|
cfg.ScheduleConfigPath = tmpFile
|
|
err = startScheduler(ctx, cfg, runner, statusStore)
|
|
if err != nil {
|
|
t.Fatalf("expected no error for valid config, got %v", err)
|
|
}
|
|
}
|
|
|
|
type mockExecutor struct {
|
|
mu sync.Mutex
|
|
dispatched int
|
|
}
|
|
|
|
func (m *mockExecutor) Execute(ctx context.Context, job jobs.Job) error {
|
|
m.mu.Lock()
|
|
m.dispatched++
|
|
m.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (m *mockExecutor) ExecuteWithResult(ctx context.Context, job jobs.Job) (jobs.Result, bool, error) {
|
|
return jobs.Result{}, false, nil
|
|
}
|
|
|
|
// mockExecutorSuccess always succeeds but returns NO import evidence.
|
|
type mockExecutorSuccess struct{}
|
|
|
|
func (m *mockExecutorSuccess) Execute(ctx context.Context, job jobs.Job) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *mockExecutorSuccess) ExecuteWithResult(ctx context.Context, job jobs.Job) (jobs.Result, bool, error) {
|
|
// No evidence: success but no import bars.
|
|
return jobs.Result{}, false, nil
|
|
}
|
|
|
|
// mockExecutorWithEvidence always succeeds and returns actual import evidence.
|
|
type mockExecutorWithEvidence struct {
|
|
bars int
|
|
instruments int
|
|
}
|
|
|
|
func (m *mockExecutorWithEvidence) Execute(ctx context.Context, job jobs.Job) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *mockExecutorWithEvidence) ExecuteWithResult(ctx context.Context, job jobs.Job) (jobs.Result, bool, error) {
|
|
return jobs.Result{
|
|
Bars: m.bars,
|
|
Instruments: m.instruments,
|
|
}, true, nil
|
|
}
|
|
|
|
func TestSchedulerLoopDispatchesTickAndStops(t *testing.T) {
|
|
schedCfg := scheduler.Config{
|
|
Schedules: []scheduler.ScheduleConfig{
|
|
{
|
|
Name: "kr-core-daily-mock",
|
|
Provider: "kis",
|
|
Selector: scheduler.SelectorConfig{
|
|
Kind: "watchlist",
|
|
Market: "KR",
|
|
Venue: "KRX",
|
|
Name: "kr-core",
|
|
Symbols: []string{"005930"},
|
|
},
|
|
Timeframe: "daily",
|
|
Cadence: "daily",
|
|
Timezone: "Asia/Seoul",
|
|
BackfillWindow: "3d",
|
|
ParallelismLimit: 2,
|
|
},
|
|
},
|
|
}
|
|
|
|
exec := &mockExecutor{}
|
|
opts := scheduler.TickOptions{
|
|
Capabilities: scheduler.CapabilityMap(kis.Capability()),
|
|
Executor: exec,
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
ticks := make(chan time.Time)
|
|
|
|
loopDone := make(chan struct{})
|
|
statusStore := scheduler.NewRefreshStatusStore()
|
|
go func() {
|
|
runSchedulerLoop(ctx, schedCfg, opts, ticks, statusStore)
|
|
close(loopDone)
|
|
}()
|
|
|
|
// Send 1 tick
|
|
ticks <- time.Now()
|
|
|
|
// Wait briefly for dispatch
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
exec.mu.Lock()
|
|
dispatchedVal := exec.dispatched
|
|
exec.mu.Unlock()
|
|
|
|
if dispatchedVal == 0 {
|
|
t.Error("expected at least 1 job dispatch on tick")
|
|
}
|
|
|
|
// Cancel context to shutdown
|
|
cancel()
|
|
|
|
select {
|
|
case <-loopDone:
|
|
// success: loop stopped
|
|
case <-time.After(1 * time.Second):
|
|
t.Error("scheduler loop did not stop on context cancellation")
|
|
}
|
|
}
|
|
|
|
// TestSchedulerLoopRecordsTickAndStatusStore verifies that a no-evidence success
|
|
// is classified as "stale" (not "success") and does NOT update last_success.
|
|
func TestSchedulerLoopRecordsTickAndStatusStore(t *testing.T) {
|
|
schedCfg := scheduler.Config{
|
|
Schedules: []scheduler.ScheduleConfig{
|
|
{
|
|
Name: "kr-core-daily-mock",
|
|
Provider: "kis",
|
|
Selector: scheduler.SelectorConfig{
|
|
Kind: "watchlist",
|
|
Market: "KR",
|
|
Venue: "KRX",
|
|
Name: "kr-core",
|
|
Symbols: []string{"005930"},
|
|
},
|
|
Timeframe: "daily",
|
|
Cadence: "daily",
|
|
Timezone: "Asia/Seoul",
|
|
BackfillWindow: "3d",
|
|
ParallelismLimit: 2,
|
|
},
|
|
},
|
|
}
|
|
|
|
// mockExecutorSuccess always succeeds but returns NO import evidence.
|
|
execSucceed := &mockExecutorSuccess{}
|
|
opts := scheduler.TickOptions{
|
|
Capabilities: scheduler.CapabilityMap(kis.Capability()),
|
|
Executor: execSucceed,
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
ticks := make(chan time.Time)
|
|
|
|
loopDone := make(chan struct{})
|
|
statusStore := scheduler.NewRefreshStatusStore()
|
|
go func() {
|
|
runSchedulerLoop(ctx, schedCfg, opts, ticks, statusStore)
|
|
close(loopDone)
|
|
}()
|
|
|
|
// Send a tick and wait for processing.
|
|
now := time.Now()
|
|
ticks <- now
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
// Verify that the status store recorded a "stale" entry (no evidence).
|
|
entry, ok := statusStore.Get("kr-core-daily-mock")
|
|
if !ok {
|
|
t.Fatal("expected status store entry for schedule after tick")
|
|
}
|
|
if entry.Status != "stale" {
|
|
t.Errorf("status = %q, want %q (no evidence → stale)", entry.Status, "stale")
|
|
}
|
|
// CRITICAL: without ImportSummary from executor, ImportedBarCount MUST be 0.
|
|
if entry.ImportedBarCount != 0 {
|
|
t.Errorf("imported_bar_count = %d, want 0 (no evidence from mock executor)", entry.ImportedBarCount)
|
|
}
|
|
// last_success must NOT be set for no-evidence.
|
|
if !entry.LastSuccess.IsZero() {
|
|
t.Errorf("last_success = %v, want zero (no evidence)", entry.LastSuccess)
|
|
}
|
|
|
|
cancel()
|
|
select {
|
|
case <-loopDone:
|
|
case <-time.After(1 * time.Second):
|
|
t.Error("scheduler loop did not stop on context cancellation")
|
|
}
|
|
}
|
|
|
|
// TestSchedulerLoopWithActualEvidence proves that when the executor returns
|
|
// actual RefreshEvidence, the status store correctly propagates imported bar counts.
|
|
func TestSchedulerLoopWithActualEvidence(t *testing.T) {
|
|
schedCfg := scheduler.Config{
|
|
Schedules: []scheduler.ScheduleConfig{
|
|
{
|
|
Name: "kr-core-daily-evidence",
|
|
Provider: "kis",
|
|
Selector: scheduler.SelectorConfig{
|
|
Kind: "watchlist",
|
|
Market: "KR",
|
|
Venue: "KRX",
|
|
Name: "kr-core",
|
|
Symbols: []string{"005930"},
|
|
},
|
|
Timeframe: "daily",
|
|
Cadence: "daily",
|
|
Timezone: "Asia/Seoul",
|
|
BackfillWindow: "3d",
|
|
ParallelismLimit: 2,
|
|
},
|
|
},
|
|
}
|
|
|
|
// mockExecutorWithEvidence returns actual import evidence.
|
|
execWithEvid := &mockExecutorWithEvidence{bars: 120, instruments: 1}
|
|
opts := scheduler.TickOptions{
|
|
Capabilities: scheduler.CapabilityMap(kis.Capability()),
|
|
Executor: execWithEvid,
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
ticks := make(chan time.Time)
|
|
|
|
loopDone := make(chan struct{})
|
|
statusStore := scheduler.NewRefreshStatusStore()
|
|
go func() {
|
|
runSchedulerLoop(ctx, schedCfg, opts, ticks, statusStore)
|
|
close(loopDone)
|
|
}()
|
|
|
|
now := time.Now()
|
|
ticks <- now
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
// Verify actual evidence propagation.
|
|
entry, ok := statusStore.Get("kr-core-daily-evidence")
|
|
if !ok {
|
|
t.Fatal("expected status store entry after tick with evidence")
|
|
}
|
|
if entry.Status != "success" {
|
|
t.Errorf("status = %q, want %q", entry.Status, "success")
|
|
}
|
|
if entry.ImportedBarCount != 120 {
|
|
t.Errorf("imported_bar_count = %d, want 120", entry.ImportedBarCount)
|
|
}
|
|
if entry.LastSuccess.IsZero() {
|
|
t.Error("last_success should be set when evidence is present")
|
|
}
|
|
|
|
cancel()
|
|
select {
|
|
case <-loopDone:
|
|
case <-time.After(1 * time.Second):
|
|
t.Error("scheduler loop did not stop on context cancellation")
|
|
}
|
|
}
|
|
|
|
func TestRecordTickSkippedItemsNotSuccess(t *testing.T) {
|
|
store := scheduler.NewRefreshStatusStore()
|
|
now := time.Now()
|
|
|
|
// All items are skipped (e.g., duplicate window running).
|
|
items := []scheduler.ItemResult{
|
|
{Schedule: "test", Symbol: "005930", Status: "skipped", Provider: "kis", Timeframe: "daily", Readiness: "stale", Freshness: "stale", Error: "duplicate window running"},
|
|
}
|
|
store.RecordTick("test", items, now)
|
|
|
|
entry, ok := store.Get("test")
|
|
if !ok {
|
|
t.Fatal("expected entry after recording skipped items")
|
|
}
|
|
if entry.Status != "stale" {
|
|
t.Errorf("status = %q, want %q", entry.Status, "stale")
|
|
}
|
|
}
|
|
|
|
func TestRecordTickFailedItemsError(t *testing.T) {
|
|
store := scheduler.NewRefreshStatusStore()
|
|
now := time.Now()
|
|
|
|
items := []scheduler.ItemResult{
|
|
{Schedule: "test", Symbol: "005930", Status: "failed", Provider: "kis", Timeframe: "daily", Readiness: "error", Freshness: "stale", Error: "connection timeout"},
|
|
}
|
|
store.RecordTick("test", items, now)
|
|
|
|
entry, ok := store.Get("test")
|
|
if !ok {
|
|
t.Fatal("expected entry after recording failed items")
|
|
}
|
|
if entry.Status != "error" {
|
|
t.Errorf("status = %q, want %q", entry.Status, "error")
|
|
}
|
|
if entry.LastError != "connection timeout" {
|
|
t.Errorf("last_error = %q, want %q", entry.LastError, "connection timeout")
|
|
}
|
|
}
|
|
|
|
func TestRecordTickMixedSucceededSkippedStale(t *testing.T) {
|
|
store := scheduler.NewRefreshStatusStore()
|
|
now := time.Now()
|
|
|
|
// Some succeeded, some skipped: should be "stale" not "success".
|
|
items := []scheduler.ItemResult{
|
|
{Schedule: "test", Symbol: "005930", Status: "succeeded", Provider: "kis", Timeframe: "daily"},
|
|
{Schedule: "test", Symbol: "000660", Status: "skipped", Provider: "kis", Timeframe: "daily", Error: "duplicate window running"},
|
|
}
|
|
store.RecordTick("test", items, now)
|
|
|
|
entry, ok := store.Get("test")
|
|
if !ok {
|
|
t.Fatal("expected entry after recording mixed items")
|
|
}
|
|
if entry.Status != "stale" {
|
|
t.Errorf("status = %q, want %q", entry.Status, "stale")
|
|
}
|
|
// imported_bar_count should be 0 for stale.
|
|
if entry.ImportedBarCount != 0 {
|
|
t.Errorf("imported_bar_count = %d, want 0 for stale", entry.ImportedBarCount)
|
|
}
|
|
}
|
|
|
|
func TestRecordTickPreservesLastSuccessOnStale(t *testing.T) {
|
|
store := scheduler.NewRefreshStatusStore()
|
|
now := time.Now()
|
|
|
|
// First: evidence-backed success.
|
|
store.RecordTick("test", []scheduler.ItemResult{
|
|
{
|
|
Schedule: "test",
|
|
Symbol: "005930",
|
|
Status: "succeeded",
|
|
Provider: "kis",
|
|
Timeframe: "daily",
|
|
ImportSummary: &scheduler.RefreshEvidence{
|
|
HasBars: true,
|
|
ImportedBars: 100,
|
|
},
|
|
},
|
|
}, now)
|
|
|
|
prev, _ := store.Get("test")
|
|
if prev.Status != "success" {
|
|
t.Fatalf("first tick status = %q, want %q", prev.Status, "success")
|
|
}
|
|
if prev.LastSuccess.IsZero() {
|
|
t.Fatal("last_success should be set after evidence-backed tick")
|
|
}
|
|
|
|
// Second: all skipped (stale). Should preserve last_success.
|
|
store.RecordTick("test", []scheduler.ItemResult{
|
|
{Schedule: "test", Symbol: "005930", Status: "skipped", Provider: "kis", Timeframe: "daily", Error: "duplicate"},
|
|
}, now.Add(1*time.Minute))
|
|
|
|
stale, _ := store.Get("test")
|
|
if stale.Status != "stale" {
|
|
t.Errorf("stale status = %q, want %q", stale.Status, "stale")
|
|
}
|
|
if !stale.LastSuccess.Equal(prev.LastSuccess) {
|
|
t.Errorf("last_success was updated on stale tick: was %v, still %v", prev.LastSuccess, stale.LastSuccess)
|
|
}
|
|
}
|
|
|
|
func TestRecordTickUnknownStatusNoSuccessEvidence(t *testing.T) {
|
|
store := scheduler.NewRefreshStatusStore()
|
|
now := time.Now()
|
|
|
|
// Unknown status should not create success evidence.
|
|
store.RecordTick("test", []scheduler.ItemResult{
|
|
{Schedule: "test", Symbol: "005930", Status: "unknown", Provider: "kis", Timeframe: "daily"},
|
|
}, now)
|
|
|
|
entry, ok := store.Get("test")
|
|
if !ok {
|
|
t.Fatal("expected entry after recording unknown status")
|
|
}
|
|
if entry.Status == "success" {
|
|
t.Errorf("status = %q for unknown item, should NOT be %q", entry.Status, "success")
|
|
}
|
|
}
|
|
|
|
func TestRecordTickZeroItemsSkipped(t *testing.T) {
|
|
store := scheduler.NewRefreshStatusStore()
|
|
now := time.Now()
|
|
|
|
// Zero items: should not create any entry.
|
|
store.RecordTick("test", []scheduler.ItemResult{}, now)
|
|
|
|
_, ok := store.Get("test")
|
|
if ok {
|
|
t.Error("expected no entry when zero items recorded")
|
|
}
|
|
}
|
|
|
|
func TestSchedulerLoopConfigWiring(t *testing.T) {
|
|
// Verify that startScheduler saves the schedule config to statusStore
|
|
// so that next_run calculations work correctly.
|
|
statusStore := scheduler.NewRefreshStatusStore()
|
|
|
|
tmpDir := t.TempDir()
|
|
tmpFile := filepath.Join(tmpDir, "schedule.yaml")
|
|
if err := os.WriteFile(tmpFile, []byte(`
|
|
schedules:
|
|
- name: kr-core-daily-wire
|
|
provider: kis
|
|
selector:
|
|
kind: watchlist
|
|
market: KR
|
|
venue: KRX
|
|
name: kr-core
|
|
symbols: ["005930"]
|
|
timeframe: daily
|
|
cadence: daily
|
|
timezone: Asia/Seoul
|
|
backfill_window: 3d
|
|
parallelism_limit: 2
|
|
`), 0644); err != nil {
|
|
t.Fatalf("failed to write temp file: %v", err)
|
|
}
|
|
|
|
cfg := config.Config{
|
|
SchedulerEnabled: true,
|
|
ScheduleConfigPath: tmpFile,
|
|
}
|
|
runner := jobs.NewRunner()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
err := startScheduler(ctx, cfg, runner, statusStore)
|
|
if err != nil {
|
|
t.Fatalf("startScheduler: %v", err)
|
|
}
|
|
|
|
// Verify that the schedule config was stored.
|
|
storedCfg := statusStore.ScheduleConfig()
|
|
if len(storedCfg.Schedules) != 1 {
|
|
t.Errorf("stored config has %d schedules, want 1", len(storedCfg.Schedules))
|
|
}
|
|
if len(storedCfg.Schedules) > 0 && storedCfg.Schedules[0].Name != "kr-core-daily-wire" {
|
|
t.Errorf("schedule name = %q, want %q", storedCfg.Schedules[0].Name, "kr-core-daily-wire")
|
|
}
|
|
}
|
|
|
|
// TestSchedulerLoopMixedEvidenceNoEvidenceSuccessPreservesLastSuccess proves that
|
|
// a no-evidence tick does NOT overwrite last_success from a previous evidence-backed
|
|
// success. After this change, no-evidence succeeds are classified as "stale" (not
|
|
// "success"), so the previous evidence-backed "success" state is preserved.
|
|
func TestSchedulerLoopMixedEvidenceNoEvidenceSuccessPreservesLastSuccess(t *testing.T) {
|
|
schedCfg := scheduler.Config{
|
|
Schedules: []scheduler.ScheduleConfig{
|
|
{
|
|
Name: "kr-core-daily-mixed",
|
|
Provider: "kis",
|
|
Selector: scheduler.SelectorConfig{
|
|
Kind: "watchlist",
|
|
Market: "KR",
|
|
Venue: "KRX",
|
|
Name: "kr-core",
|
|
Symbols: []string{"005930"},
|
|
},
|
|
Timeframe: "daily",
|
|
Cadence: "daily",
|
|
Timezone: "Asia/Seoul",
|
|
BackfillWindow: "3d",
|
|
ParallelismLimit: 2,
|
|
},
|
|
},
|
|
}
|
|
|
|
execWithEvid := &mockExecutorWithEvidence{bars: 100, instruments: 1}
|
|
opts := scheduler.TickOptions{
|
|
Capabilities: scheduler.CapabilityMap(kis.Capability()),
|
|
Executor: execWithEvid,
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
ticks := make(chan time.Time)
|
|
loopDone := make(chan struct{})
|
|
statusStore := scheduler.NewRefreshStatusStore()
|
|
go func() {
|
|
runSchedulerLoop(ctx, schedCfg, opts, ticks, statusStore)
|
|
close(loopDone)
|
|
}()
|
|
|
|
now := time.Now()
|
|
ticks <- now
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
// Verify evidence-backed success.
|
|
entry1, ok := statusStore.Get("kr-core-daily-mixed")
|
|
if !ok {
|
|
t.Fatal("expected entry after evidence-backed tick")
|
|
}
|
|
if entry1.Status != "success" {
|
|
t.Errorf("status = %q, want %q", entry1.Status, "success")
|
|
}
|
|
if entry1.ImportedBarCount != 100 {
|
|
t.Errorf("imported_bar_count = %d, want 100", entry1.ImportedBarCount)
|
|
}
|
|
if entry1.LastSuccess.IsZero() {
|
|
t.Fatal("last_success should be set after evidence-backed tick")
|
|
}
|
|
lastSuccessAfterEvidence := entry1.LastSuccess
|
|
|
|
cancel()
|
|
select {
|
|
case <-loopDone:
|
|
case <-time.After(1 * time.Second):
|
|
t.Error("scheduler loop did not stop on context cancellation")
|
|
}
|
|
|
|
// Now test no-evidence preservation via direct RecordTick calls.
|
|
// This validates that calling RecordTick with no-evidence (ImportSummary == nil)
|
|
// does NOT overwrite the previous evidence-backed "success" state.
|
|
entry2, _ := statusStore.Get("kr-core-daily-mixed")
|
|
if entry2.Status != "success" {
|
|
t.Errorf("pre-test status = %q, want %q", entry2.Status, "success")
|
|
}
|
|
|
|
// RecordTick with no-evidence success item.
|
|
statusStore.RecordTick("kr-core-daily-mixed", []scheduler.ItemResult{
|
|
{Schedule: "kr-core-daily-mixed", Symbol: "005930", Status: "succeeded", Provider: "kis", Timeframe: "daily", Readiness: "ready", Freshness: "fresh"},
|
|
}, now.Add(1*time.Minute))
|
|
|
|
entry3, ok := statusStore.Get("kr-core-daily-mixed")
|
|
if !ok {
|
|
t.Fatal("expected entry after RecordTick")
|
|
}
|
|
// No-evidence tick replaces previous "success" → the entry becomes "stale"
|
|
// because all items have no evidence. The previous last_success must NOT
|
|
// be overwritten, but the status itself is now "stale".
|
|
if entry3.Status != "stale" {
|
|
t.Errorf("post-no-evidence status = %q, want %q (no evidence → stale)", entry3.Status, "stale")
|
|
}
|
|
// last_success must NOT be updated by no-evidence tick.
|
|
if !entry3.LastSuccess.Equal(lastSuccessAfterEvidence) {
|
|
t.Errorf("last_success = %v, want %v (preserved from evidence tick)", entry3.LastSuccess, lastSuccessAfterEvidence)
|
|
}
|
|
// imported_bar_count stays the same.
|
|
if entry3.ImportedBarCount != 100 {
|
|
t.Errorf("imported_bar_count = %d, want 100 (no evidence to add)", entry3.ImportedBarCount)
|
|
}
|
|
}
|
|
|
|
// TestDirectRecordTickNoEvidenceSuccessNoLastSuccess verifies that no-evidence
|
|
// success is classified as "stale" and does NOT set last_success.
|
|
func TestDirectRecordTickNoEvidenceSuccessNoLastSuccess(t *testing.T) {
|
|
store := scheduler.NewRefreshStatusStore()
|
|
now := time.Now()
|
|
|
|
// Direct RecordTick with no-evidence success (ImportSummary == nil).
|
|
items := []scheduler.ItemResult{
|
|
{Schedule: "test", Symbol: "005930", Status: "succeeded", Provider: "kis", Timeframe: "daily", Readiness: "ready", Freshness: "fresh"},
|
|
}
|
|
store.RecordTick("test", items, now)
|
|
|
|
entry, ok := store.Get("test")
|
|
if !ok {
|
|
t.Fatal("expected entry")
|
|
}
|
|
if entry.Status != "stale" {
|
|
t.Errorf("status = %q, want %q (no evidence → stale)", entry.Status, "stale")
|
|
}
|
|
// No evidence: status is "stale", so last_success must NOT be set.
|
|
if !entry.LastSuccess.IsZero() {
|
|
t.Errorf("last_success = %v, want zero (no evidence)", entry.LastSuccess)
|
|
}
|
|
// No evidence: ImportedBarCount should be 0.
|
|
if entry.ImportedBarCount != 0 {
|
|
t.Errorf("imported_bar_count = %d, want 0", entry.ImportedBarCount)
|
|
}
|
|
}
|
|
|
|
// TestDirectRecordTickEvidenceSuccess sets last_success.
|
|
func TestDirectRecordTickEvidenceSuccessSetsLastSuccess(t *testing.T) {
|
|
store := scheduler.NewRefreshStatusStore()
|
|
now := time.Now()
|
|
|
|
items := []scheduler.ItemResult{
|
|
{
|
|
Schedule: "test",
|
|
Symbol: "005930",
|
|
Status: "succeeded",
|
|
Provider: "kis",
|
|
Timeframe: "daily",
|
|
Readiness: "ready",
|
|
Freshness: "fresh",
|
|
ImportSummary: &scheduler.RefreshEvidence{
|
|
HasBars: true,
|
|
ImportedBars: 150,
|
|
},
|
|
},
|
|
}
|
|
store.RecordTick("test", items, now)
|
|
|
|
entry, ok := store.Get("test")
|
|
if !ok {
|
|
t.Fatal("expected entry")
|
|
}
|
|
if entry.Status != "success" {
|
|
t.Errorf("status = %q, want %q", entry.Status, "success")
|
|
}
|
|
if entry.ImportedBarCount != 150 {
|
|
t.Errorf("imported_bar_count = %d, want 150", entry.ImportedBarCount)
|
|
}
|
|
if entry.LastSuccess.IsZero() {
|
|
t.Error("last_success should be set when evidence is present")
|
|
}
|
|
}
|
|
|
|
// TestDirectRecordTickMixedEvidenceDoesNotUpgradeSuccess proves that a mixed
|
|
// tick (some succeeded with evidence, some succeeded without) does NOT silently
|
|
// upgrade to full success evidence. Only items with evidence contribute bar
|
|
// counts; the status is still "success" because all items succeeded, but
|
|
// last_success is only updated when hasEvidence is true.
|
|
func TestDirectRecordTickMixedEvidenceDoesNotUpgradeSuccess(t *testing.T) {
|
|
store := scheduler.NewRefreshStatusStore()
|
|
now := time.Now()
|
|
|
|
// Two items: one with evidence, one without.
|
|
items := []scheduler.ItemResult{
|
|
{
|
|
Schedule: "test",
|
|
Symbol: "005930",
|
|
Status: "succeeded",
|
|
Provider: "kis",
|
|
Timeframe: "daily",
|
|
ImportSummary: &scheduler.RefreshEvidence{
|
|
HasBars: true,
|
|
ImportedBars: 100,
|
|
},
|
|
},
|
|
{
|
|
Schedule: "test",
|
|
Symbol: "000660",
|
|
Status: "succeeded",
|
|
Provider: "kis",
|
|
Timeframe: "daily",
|
|
// No ImportSummary — no evidence.
|
|
},
|
|
}
|
|
store.RecordTick("test", items, now)
|
|
|
|
entry, ok := store.Get("test")
|
|
if !ok {
|
|
t.Fatal("expected entry")
|
|
}
|
|
// All items succeeded → status is "success" even with mixed evidence.
|
|
if entry.Status != "success" {
|
|
t.Errorf("status = %q, want %q", entry.Status, "success")
|
|
}
|
|
// Only the item with evidence contributes bar counts.
|
|
if entry.ImportedBarCount != 100 {
|
|
t.Errorf("imported_bar_count = %d, want 100", entry.ImportedBarCount)
|
|
}
|
|
// hasEvidence is true (at least one item has evidence) → last_success is set.
|
|
if entry.LastSuccess.IsZero() {
|
|
t.Error("last_success should be set when hasEvidence is true (mixed with at least one evidence)")
|
|
}
|
|
}
|